Merge campaign-hover-ui-round: the overnight hover/UI round — world-tooltip fix, Config clip, remaining tooltip surfaces, the Map/House panel
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Batch A: world tooltips orphaned per hover transition (one-popup invariant, live-verified 103 mounts/102 removes) + the UiTemplateListBox viewport baseline-capture race clipping the Config tab. Batch B: cast-button + character-panel attribute/vitals/skill tooltips (InqSkillFormula recovered from unlabeled fragments; 34 skills live-verified). Batch C+closers: the retail Map/House toolbar panel — panel id 16 at slot 0x1000018C, button 0x1000019A, the byte-decoded PlaceMarkerOnMap projection (span-normalized, north-up), the verbatim 53-town table with hover tooltips, the Dereth date/time line, coords readout, login-time HouseQuery + RuntimeHouseState with retail's single houseless sentence. Opus round review F1-F15 fixed (the marker formula was a BN FPU-elision misread — byte-re-derived; three 'unrecoverable' strings recovered; AD-107 retired, AD-108/IA-23 filed). Final numeric live verification: ring at exactly the computed pixel (226,125), Holtburg hover tooltip, House text, graceful logout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
3fc626cc4f
50 changed files with 9207 additions and 72 deletions
262
docs/ISSUES.md
262
docs/ISSUES.md
|
|
@ -24,6 +24,220 @@ What does NOT go here:
|
||||||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||||
|
|
||||||
|
## #413 — House tab shows no content (owned-house display, six Display* line builders unported)
|
||||||
|
|
||||||
|
**Status:** NARROWED 2026-08-17 (House-tab ownership-text closer session);
|
||||||
|
item 2's not-expired branch closed same-day at the night-round review fix
|
||||||
|
round (F8). Items 1 and 2 below are DONE; item 3 (six owned-house-only
|
||||||
|
builders) remains OPEN and is the entire remaining scope.
|
||||||
|
|
||||||
|
**What's shipped (this session, on top of Batch C's mount + parser
|
||||||
|
groundwork).**
|
||||||
|
|
||||||
|
1. **`RuntimeHouseState`** (`src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs`)
|
||||||
|
— the minimal owner ISSUES originally called for ("a lighter read-only
|
||||||
|
mirror... no full owner ceremony"): no `GameRuntimeConstructionPoint`
|
||||||
|
fault-injection entry, no `IDisposable`/`construction.Own`, since it holds
|
||||||
|
no live-object side effects. It DOES participate in
|
||||||
|
`RuntimeGenerationReset` (new stage `RuntimeGenerationResetStage.House`,
|
||||||
|
between `Trade` and `BeginEntityRetirement`) since a fresh login must not
|
||||||
|
show a previous character's house-query result. Wired end-to-end:
|
||||||
|
`GameEventWiring`'s `onHouseData`/`onHouseStatus` delegate holes →
|
||||||
|
`LiveSessionEventRouter`'s new `LiveSocialSessionBindings.House` →
|
||||||
|
`GameRuntime.HouseOwner` → `MapHouseRuntimeBindings.HouseLines`/
|
||||||
|
`HouseShown` → `HousePageController`.
|
||||||
|
|
||||||
|
2. **`gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s expired branch** —
|
||||||
|
ported faithfully in `RuntimeHouseState.Recompute`: local player
|
||||||
|
`PropertyInt.HousePurchaseTimestamp` (199 decimal) via
|
||||||
|
`ClientObjectTable`, `HouseSystem::HasPurchaseWaitPeriodExpired(timestamp)
|
||||||
|
= (nowEpoch - timestamp) > 0x278d00` (2,592,000 s = 30 days), and the two
|
||||||
|
literal strings gated on `m_pHouseData == 0`. A fresh `+Acdream`-shaped
|
||||||
|
character (no `HousePurchaseTimestamp` ever set) shows **exactly one
|
||||||
|
line**: "You may buy another house immediately." — matching this issue's
|
||||||
|
OWN original acceptance-test wording below, byte-verified against
|
||||||
|
`data_7ab7f0` in the decomp. **The not-expired `strftime`-formatted
|
||||||
|
branch is now ALSO ported (night-round review, F8, 2026-08-17) — the
|
||||||
|
"BN-unrecoverable format string" claim was wrong.** A direct capstone
|
||||||
|
disassembly of the raw bytes at `gmHouseUI::DisplayPurchaseTimeText`'s
|
||||||
|
not-expired branch resolves all three literal pieces retail
|
||||||
|
concatenates: prefix `"You may buy another landscape house at "`
|
||||||
|
(`data_7ab790`), the `strftime("%c", ...)`-formatted expiry moment
|
||||||
|
(`timestamp + 0x278d00`, i.e. 30 days after the purchase timestamp), and
|
||||||
|
suffix `". This restriction does not apply to apartments."`
|
||||||
|
(`data_7ab7b8`). `RuntimeHouseState.Recompute` now renders this exactly,
|
||||||
|
substituting .NET's culture-default `DateTime.ToString()` for the CRT's
|
||||||
|
`strftime("%c", ...)` (a different formatting engine, same "process
|
||||||
|
locale, full date+time" intent — filed as register row IA-23, an
|
||||||
|
approximation, not a gap).
|
||||||
|
|
||||||
|
**Corrects a framing this session's task brief carried in from outside
|
||||||
|
this doc**: the brief described retail as ALSO showing a preceding line
|
||||||
|
"You do not currently own a house." No such string, in that or any close
|
||||||
|
wording, exists anywhere in the 2013 EoR `acclient_2013_pseudo_c.txt`
|
||||||
|
dump, in any `gmHouseUI`/`gmMapUI` method, in ACE's `GameEventHouseStatus`
|
||||||
|
writer, or in the live-DAT House ListBox/page (re-confirmed empty this
|
||||||
|
session — `MapHousePanelSlotProbeTests`, zero rows, zero sibling
|
||||||
|
content). The closest strings found are UNRELATED generic command-error
|
||||||
|
chat text ("You do not own a house!", WeenieError `0x45E`/`0x45F`; "You
|
||||||
|
must own a house to use this command.", WeenieError `0x47F`), routed
|
||||||
|
through the GENERIC WeenieError-to-chat dispatcher, never through
|
||||||
|
`gmHouseUI`. This ISSUES entry's OWN pre-existing "Acceptance test once
|
||||||
|
closed" line below (written before this session, by the same research
|
||||||
|
pass that produced the recon doc) already named the single-line
|
||||||
|
"You may buy another house immediately." text as the target — this
|
||||||
|
session's mechanism derivation independently reached the same
|
||||||
|
conclusion and is now the shipped, tested behavior.
|
||||||
|
|
||||||
|
**Also fixed in the same pass: `HousePageController.Bind` never wired
|
||||||
|
`UiTemplateListBox.TemplateResolver`.** Without it,
|
||||||
|
`AddItemFromTemplateList` always returns null (no resolver = no row) —
|
||||||
|
the ListBox would have stayed visually empty regardless of `Lines`
|
||||||
|
content. `HousePageController.Bindings` gained a `TemplateResolver`
|
||||||
|
parameter, wired in `Bind`; `RetailUiRuntime.MountMapHousePanel` supplies
|
||||||
|
the SAME generic `ResolveHotspotTemplate` the Map tab's town hotspots
|
||||||
|
already use (a plain `(layoutId, elementId) -> UiElement` resolve+build,
|
||||||
|
nothing map-specific about it despite the binding's name).
|
||||||
|
|
||||||
|
**What remains open — item 3, the entire surviving scope:**
|
||||||
|
|
||||||
|
3. **The six owned-house-only `Display*` line builders** (`DisplayBuyPayment`,
|
||||||
|
`DisplayRentPayment`, `DisplayBuyTime`, `DisplayRentTimes`,
|
||||||
|
`DisplayLocation`, `DisplayWarningText` — all called from
|
||||||
|
`gmHouseUI::DisplayHouseData @0x004a3380`). Each is dozens-to-a-few-hundred
|
||||||
|
lines of heavily FPU/string-mangled BN pseudo-C (PStringBase sprintf
|
||||||
|
chains, `HousePaymentList` iteration, `IsPaidInFull`/
|
||||||
|
`ConstructRentWarningMessage`-style formatting) — genuinely sized as its
|
||||||
|
own session, and only exercisable once a test character actually owns a
|
||||||
|
house (not true of `+Acdream` today; `RuntimeHouseState.ApplyHouseData`
|
||||||
|
is wired and tested against a synthetic `GameEvents.HouseData`, but has
|
||||||
|
never been exercised against a real ACE-owned house). `DisplayLocation`
|
||||||
|
is the exception: its own logic is clean (`GetHouseLocation` →
|
||||||
|
`LandDefs::gid_to_lcoord` → the SAME `(v-0x400)*0.1+0.5` transform the
|
||||||
|
Map tab already ports via `RadarCoordinates`) but its output STRING
|
||||||
|
format is BN-mangled the same way the Map tab's coordinate readout was —
|
||||||
|
reuse whatever resolution that gets if/when #413's map coordinate format
|
||||||
|
string is independently recovered.
|
||||||
|
|
||||||
|
**Reference:** `docs/research/2026-08-17-map-house-recon.md` (the recon);
|
||||||
|
`src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (this session's owner,
|
||||||
|
full citation set in its own class doc); `docs/architecture/retail-divergence-register.md`
|
||||||
|
AD-107 (the HouseQuery-on-tab-open trigger adaptation);
|
||||||
|
`src/AcDream.App/UI/Layout/HousePageController.cs`,
|
||||||
|
`src/AcDream.Core.Net/Messages/GameEvents.cs` (House parsers),
|
||||||
|
`src/AcDream.Core.Net/GameEventWiring.cs` (delegate holes).
|
||||||
|
|
||||||
|
**Acceptance test — CLOSED for the houseless case, LIVE-VERIFIED, still the
|
||||||
|
target for the owned-house case.** The House tab, on a fresh `+Acdream`
|
||||||
|
connect with no owned house, shows "You may buy another house
|
||||||
|
immediately." after the tab is opened (client sends `HouseQuery`, ACE
|
||||||
|
replies `HouseStatus`, `RuntimeHouseState.ApplyHouseStatus` fires,
|
||||||
|
`Recompute`'s expired/no-house branch renders the line) — unit-tested
|
||||||
|
(`RuntimeHouseStateTests.HouseStatus_FreshCharacterWithNoTimestamp_ShowsBuyImmediatelyLine`),
|
||||||
|
fixture-tested end-to-end through the real row template
|
||||||
|
(`MapHousePanelControllerTests.Tick_RendersHouseLinesIntoTheAuthoredRowTemplate`),
|
||||||
|
and CONNECTED-GATE-VERIFIED 2026-08-17 against a real local ACE server and
|
||||||
|
the real `+Acdream` character (guid `0x5000000A`): a `--session-config`
|
||||||
|
launch (auto-selecting the character to bypass the interactive
|
||||||
|
character-select screen) plus a `ACDREAM_UI_PROBE_SCRIPT` automation script
|
||||||
|
(click the Map/House toolbar button `0x1000019A`, switch to the House tab
|
||||||
|
`0x100001F4`, dump the live UI tree, screenshot) produced a screenshot
|
||||||
|
showing the House tab's ListBox rendering exactly "You may buy another
|
||||||
|
house immediately." and a structural UI dump confirming the House page
|
||||||
|
(`0x100001F7`), its ListBox (`0x100001E6`), and its ONE rendered row
|
||||||
|
(`0x100001E7`, the authored template) all visible and correctly placed. Both
|
||||||
|
launches ended with an ACE-confirmed graceful logout
|
||||||
|
(`[session] graceful logout confirmed`). Still owed: the owned-house case
|
||||||
|
once item 3 lands.
|
||||||
|
|
||||||
|
## #412 — Options panel Config tab content escapes the window frame (footer mid-panel, rows drawing below the window's bottom edge)
|
||||||
|
|
||||||
|
**Status:** DONE 2026-08-16/17 (overnight hover/UI round, Batch A bug 2).
|
||||||
|
|
||||||
|
**Symptom (user screenshot):** the Config tab's Sound/Camera/Graphics/Rendering
|
||||||
|
Quality sections rendered with the Apply/Reset/Defaults footer sitting
|
||||||
|
mid-panel and further rows (Full Screen, Sync With Refresh Rate, Screen
|
||||||
|
Brightness, Adaptive Degrade, the quality dropdowns...) drawing BELOW the
|
||||||
|
window's bottom edge, outside the panel frame — not clipped to the window,
|
||||||
|
not reachable by scrolling. The user noted seeing this class of bug before
|
||||||
|
("another bug that I saw before as well") — a related but distinct symptom,
|
||||||
|
the CONTENT-behind-the-footer bleed-through, was already fixed as #381; this
|
||||||
|
is content escaping the WHOLE window, not just showing through the footer
|
||||||
|
strip.
|
||||||
|
|
||||||
|
**Root cause — a stale anchor-baseline capture, not a missing clip.**
|
||||||
|
Live-DAT measured (`0x2100006E` slot `0x1000018D`): the merged tab-host root
|
||||||
|
is authored 300×362 (retail's real default window size — matches the floaty
|
||||||
|
frame's own 310×372 root), but the Config page slot underneath
|
||||||
|
(`0x10000213`) keeps its own larger authored design geometry, 298×575
|
||||||
|
against a 300×600 design canvas — retail's real
|
||||||
|
`UIElement::UpdateForParentSizeChange @0x00462640` four-edge policy
|
||||||
|
(`L=T=R=B=1`, "preserve original margin on every edge") correctly shrinks
|
||||||
|
that slot to ~298×337 on the first `ApplyAnchor` layout pass, and the Config
|
||||||
|
ListBox (`0x10000200`, 276×560) shrinks right behind it via the SAME
|
||||||
|
per-element `UiLayoutPolicy` mechanism — both verified stable over repeated
|
||||||
|
simulated frames. The actual bug: `UiTemplateListBox.Viewport` (the
|
||||||
|
`UiScrollablePanel` that hosts + clips every row) is a PROGRAMMATIC C#
|
||||||
|
element seeded at `ConfigOptionsPageController.Bind` time — BEFORE the
|
||||||
|
tree's first real draw frame, i.e. before the ListBox has ever shrunk. Its
|
||||||
|
legacy `Left|Top|Right|Bottom` anchor baseline is captured lazily, on ITS
|
||||||
|
own first `ApplyAnchor` call, which lands AFTER the ListBox has already
|
||||||
|
shrunk earlier in that SAME frame (parent-before-child draw order) — so the
|
||||||
|
capture measures a NEGATIVE bottom margin (`parentH(297) - (0+560) = -263`)
|
||||||
|
that `ComputeAnchoredRect`'s stretch math preserves FOREVER: the viewport
|
||||||
|
stayed locked at its original 560px design height, clipping rows to a bound
|
||||||
|
retail never actually gave the window on screen. Rows past the real ~297px
|
||||||
|
stayed "visible" per the cull test and painted straight through the footer
|
||||||
|
and past the window's real bottom edge.
|
||||||
|
|
||||||
|
**Fix (mechanism, not a workaround):** `UiTemplateListBox.Viewport`'s getter
|
||||||
|
now calls `_viewport.CaptureCurrentAnchorBaseline()` immediately after
|
||||||
|
seeding it — forcing the anchor capture to happen NOW, while the viewport's
|
||||||
|
own Width/Height still exactly equal a zero-margin baseline against its
|
||||||
|
CURRENT (pre-shrink) parent, instead of lazily on the first real draw frame
|
||||||
|
against an ALREADY-shrunk parent. `ComputeAnchoredRect` then tracks whatever
|
||||||
|
height the ListBox actually ends up at after its own `LayoutPolicy` runs, on
|
||||||
|
every subsequent frame — exactly #372's original intent (#372 fixed the 0×0
|
||||||
|
collapse case; this is #372's sequel for the "ListBox itself later shrinks"
|
||||||
|
case, which #372's own fixture never exercised because its harness ListBox
|
||||||
|
had no parent to shrink it).
|
||||||
|
`src/AcDream.App/UI/UiTemplateListBox.cs`.
|
||||||
|
|
||||||
|
**Tests:** `UiTemplateListBoxViewportTests.Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout`
|
||||||
|
(synthetic two-level `UiLayoutPolicy` parent chain using the live-DAT-measured
|
||||||
|
298×575/276×560 numbers) and two `ConfigOptionsPageControllerTests` fixture
|
||||||
|
regressions
|
||||||
|
(`ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass`,
|
||||||
|
`ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass`)
|
||||||
|
against the REAL production `ConfigOptionsPageController.Bind` path and the
|
||||||
|
committed `options_panel_2100006E_1000018D.json` fixture. All three fail
|
||||||
|
pre-fix (red-green confirmed by temporarily reverting the fix) and pass
|
||||||
|
post-fix. Full App suite (5437/3 skips), Runtime (1735/0), and the complete
|
||||||
|
solution (14,575 tests) pass with the fix in place.
|
||||||
|
|
||||||
|
**Blast-radius note (task-required):** the fix is scoped to
|
||||||
|
`UiTemplateListBox`'s own lazily-created viewport — it does not touch
|
||||||
|
`UiScrollablePanel`, `UiElement.ApplyAnchor`, or `ComputeAnchoredRect`
|
||||||
|
themselves, so chat's transcript scrolling and every other
|
||||||
|
`UiScrollablePanel`/`UiItemList` consumer (inventory grids, spell/component
|
||||||
|
catalogs, Chat tab's own filter blocks) are unaffected — confirmed by the
|
||||||
|
full solution run passing with no new failures anywhere outside the two
|
||||||
|
files this fix touches. The ONLY other `UiTemplateListBox` consumers are the
|
||||||
|
Character and Chat Options tabs, which share the identical
|
||||||
|
Bind-before-first-frame ordering and are now protected by the SAME fix.
|
||||||
|
|
||||||
|
**Live check not performed:** the fix is proven via live-DAT-measured
|
||||||
|
geometry (real installed DAT numbers feeding both the regression tests and
|
||||||
|
this writeup) plus the fixture path that mirrors the exact production
|
||||||
|
`RetailUiRuntime.MountOptionsPanel` sequence, but the actual visual
|
||||||
|
Config-tab-in-window check was not done live (no interactive desktop
|
||||||
|
consent available this session — see #409's own note on the same
|
||||||
|
constraint). Owed: open Options -> Config with `ACDREAM_RETAIL_UI=1` and
|
||||||
|
confirm the footer and every row stay inside the window frame, with the
|
||||||
|
scrollbar reaching every row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state
|
## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state
|
||||||
|
|
||||||
**Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the
|
**Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the
|
||||||
|
|
@ -281,7 +495,11 @@ NAME, `"%d %s"`-prefixed when the stack is > 1), which stays deferred: acdream's
|
||||||
`UiItemSlot` is constructed programmatically at 6+ sites and carries neither the
|
`UiItemSlot` is constructed programmatically at 6+ sites and carries neither the
|
||||||
`P0x47` popup locator nor a name source, so porting it is its own slice, not a
|
`P0x47` popup locator nor a name source, so porting it is its own slice, not a
|
||||||
one-line seam. Register TS-85 is narrowed accordingly and now enumerates all 15
|
one-line seam. Register TS-85 is narrowed accordingly and now enumerates all 15
|
||||||
`SetTooltip` call sites split into ported / no-acdream-analog.
|
`SetTooltip` call sites split into ported / no-acdream-analog. **[F12 correction,
|
||||||
|
night-round review, 2026-08-17: this was actually 17 sites, not 15 — the count
|
||||||
|
dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` and undercounted by
|
||||||
|
one more besides; see register row TS-85's own current text for the corrected
|
||||||
|
17-site (16 ported + `RecalculateTruncation` open) tally.]**
|
||||||
|
|
||||||
**2026-08-16 review-fix round (F1-F11), same day.** An Opus review of the
|
**2026-08-16 review-fix round (F1-F11), same day.** An Opus review of the
|
||||||
port above returned architectural PASS-with-findings / retail-fidelity FAIL
|
port above returned architectural PASS-with-findings / retail-fidelity FAIL
|
||||||
|
|
@ -480,6 +698,48 @@ pointer should swap to its "found" variant; hover an NPC/creature — a name
|
||||||
tooltip should appear immediately (no perceptible delay) if "Show Tooltips"
|
tooltip should appear immediately (no perceptible delay) if "Show Tooltips"
|
||||||
is on; hover a sign/chest/portal similarly.
|
is on; hover a sign/chest/portal similarly.
|
||||||
|
|
||||||
|
**2026-08-16/17 overnight hover/UI round, Batch A bug 1 — CLOSED same round:
|
||||||
|
world tooltips never cleared, stacking dozens of popups.** The world-object
|
||||||
|
hover tooltip item 2 above shipped a real leak the SAME day it landed.
|
||||||
|
`RetailTooltipPresenter.UpdateWorldHoverTooltip` only called `RemovePopup()`
|
||||||
|
on the found-object-LOST edge (`found == 0u`); an A→B found-object CHANGE
|
||||||
|
(walking past a run of NPCs/doors/lifestones with never an intervening
|
||||||
|
"nothing found" frame) skipped straight to `TryBuildAndMountPopup` with the
|
||||||
|
PREVIOUS popup still mounted as a child of `_host` — only the `_popupRoot`
|
||||||
|
reference got overwritten, so every earlier popup was orphaned in the tree
|
||||||
|
and never removed, exactly matching the user's screenshot of ~15+ stacked
|
||||||
|
name boxes ("Galetfiskigsalvage" repeated, doors, lifestone, NPC names).
|
||||||
|
Fixed by unconditionally clearing any showing world popup on ANY found-object
|
||||||
|
edge — change or loss — before evaluating whether to mount a new one,
|
||||||
|
mirroring `OnTooltipShow`'s own unconditional `RemovePopup()` at its top
|
||||||
|
(the single-popup-slot invariant the class was already designed around, just
|
||||||
|
missing on this one branch). `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs`.
|
||||||
|
Two new fixture regressions
|
||||||
|
(`RetailTooltipPresenterTests.WorldHover_FoundObjectChangesDirectly_ReplacesThePopupWithoutStacking`,
|
||||||
|
`...WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks`) both fail
|
||||||
|
pre-fix (red-green confirmed) — the gap existed because no prior test
|
||||||
|
exercised a direct A→B found-object transition, only A→0 and 0→A.
|
||||||
|
|
||||||
|
**Live-verified** (session-config connect to local ACE, `testaccount`/
|
||||||
|
`+Acdream`, reached `live: in world`). Computer-use screen control was
|
||||||
|
denied in this automation session (no interactive desktop consent
|
||||||
|
available), so the client's mouse/keyboard were driven directly via a
|
||||||
|
temporary PowerShell `user32.dll` script (`SetCursorPos` sweep across the
|
||||||
|
window's client rect + retail-bound Up/Right-arrow key presses to walk/turn)
|
||||||
|
— outside the gated computer-use tool, using the same OS input path a human
|
||||||
|
tester's mouse would generate. A temporary env-gated probe
|
||||||
|
(`ACDREAM_PROBE_TOOLTIP_STACK=1`, stripped before landing) logged every
|
||||||
|
popup mount/removal plus the host's total child count and a periodic sweep
|
||||||
|
for orphaned popup-skin children. Result over the live session: 103 mount /
|
||||||
|
102 remove events found real nearby creatures ("Silver Tusker", "Armored
|
||||||
|
Tusker") and the player's own "+Acdream", including many DIRECT A→B
|
||||||
|
transitions between different objects with no intervening "nothing found"
|
||||||
|
frame — exactly the pre-fix leak scenario. `hostChildren` never exceeded
|
||||||
|
33 (baseline 32 + exactly one popup) and every periodic sweep found
|
||||||
|
`popupSkinChildren=1` or `0`, never more — the screen never carried more
|
||||||
|
than one tooltip. Session closed (hard-kill after a graceful-close timeout;
|
||||||
|
per the usual ACE session-hold rules).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Original GF-16 filing (superseded by the re-derivation above; kept for
|
**Original GF-16 filing (superseded by the re-derivation above; kept for
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
295
docs/research/2026-08-17-map-house-recon.md
Normal file
295
docs/research/2026-08-17-map-house-recon.md
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
# Map/House toolbar panel — recon (Batch C, overnight hover/UI round)
|
||||||
|
|
||||||
|
Combines the recon handed to this session (address-level findings, verified)
|
||||||
|
with additional desk verification done before implementation: several facts
|
||||||
|
the handoff marked "UNKNOWN" or "likely" were already resolved elsewhere in
|
||||||
|
the repo, and are corrected here.
|
||||||
|
|
||||||
|
## Panel identity — RESOLVED (corrects the handoff's guess)
|
||||||
|
|
||||||
|
The handoff guessed the panel id was "likely one of RetailPanelCatalog's
|
||||||
|
unused ids {1,2,6,14}". That guess is **wrong** — the answer was already on
|
||||||
|
disk from the FA campaign's own full 16-slot `gmPanelUI::SetupChildren`
|
||||||
|
dump (`docs/research/2026-08-11-fa-panel-structure.md:927-933`, itself
|
||||||
|
byte-verified against the live installed DATs at FA3):
|
||||||
|
|
||||||
|
```
|
||||||
|
0x1000018C = 16 gmMapUI+gmHouseUI pages
|
||||||
|
0x10000559 = 25 gmJournalUI/gmPageListUI/gmContractsUI (NOT this batch)
|
||||||
|
```
|
||||||
|
|
||||||
|
So: **host `0x2100006E`, slot `0x1000018C`, `RetailPanelCatalog` id `16`.**
|
||||||
|
`gmPanelUI::SetupChildren @0x004bc9e0` (pc:195832) confirms the full 16-slot
|
||||||
|
enumeration is exhaustive — `0x10000186` inside the contiguous id run
|
||||||
|
resolves to nothing (`IMPORT NULL`), consistent with the FA doc's flag.
|
||||||
|
|
||||||
|
Toolbar button: cross-referenced the committed fixture
|
||||||
|
`tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json` (last
|
||||||
|
regenerated 2026-08-11, Campaign OP slice OP5 — same DAT install this
|
||||||
|
session uses) for each of `ToolbarController.PanelButtonIds`' own
|
||||||
|
`P0x10000029` value:
|
||||||
|
|
||||||
|
| Button element | `P0x10000029` | Panel |
|
||||||
|
|---|---|---|
|
||||||
|
| `0x1000055A` | 25 | Journal (ghosted, out of scope) |
|
||||||
|
| `0x10000197` | **12** | Social — **currently ghosted despite FA docs claiming "no toolbar button authors this id"; flagged, not chased (out of scope for this batch)** |
|
||||||
|
| `0x10000198` | 13 | Magic (registered) |
|
||||||
|
| `0x10000199` | 11 | Character (registered) |
|
||||||
|
| `0x100001B1` | 7 | Inventory (registered) |
|
||||||
|
| `0x1000019A` | **16** | **Map/House — THE button this batch un-ghosts** |
|
||||||
|
| `0x1000019B` | 10 | Options (registered) |
|
||||||
|
|
||||||
|
Slice 1's live probe re-confirms both facts (slot table + button) against
|
||||||
|
the live DAT rather than trusting the committed fixture at face value.
|
||||||
|
|
||||||
|
## gmMapUI — decompiled, byte-exact
|
||||||
|
|
||||||
|
Retail source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||||||
|
|
||||||
|
- `gmMapUI::PostInit @0x004a1c70` (pc:171993): resolves
|
||||||
|
`m_pDateTimeText=0x100001eb` (`UIElement_Text`, DynamicCast 0xc),
|
||||||
|
`m_pCoordinateText=0x100001ef` (`UIElement_Text`),
|
||||||
|
`m_pPlayerLocationIcon=0x100001ed`, `m_pHouseLocationIcon=0x100001ee`,
|
||||||
|
`m_pMap=0x100001ec`. Reads `m_pMap`'s own int attrs `0x1000004e/4f/50/51`
|
||||||
|
into `m_boxMapMarkerArea` (x0,x1,y0,y1) — the marker-area rect. Reads
|
||||||
|
`m_pMap`'s enum attr `0x47` (**literal small property id, NOT
|
||||||
|
`0x10000047`**) and DataID attr `0x48` into a `QualifiedDataID(id, 0x23)`
|
||||||
|
(category `0x23` = LayoutDesc) → `DBObj::Get`. If that resolves, loops the
|
||||||
|
53-entry `s_rgLocations` table calling `AddMapNote(this, m_pMap, var_c
|
||||||
|
/*=attr 0x47, the template ELEMENT id*/, eax_10 /*=the resolved
|
||||||
|
LayoutDesc*/, &s_rgLocations[i])` for each. **So `0x47`/`0x48` on `m_pMap`
|
||||||
|
together name a template (LayoutDesc, element) pair for the per-town
|
||||||
|
hotspot widget** — the exact same "authored template" pattern
|
||||||
|
`Layout.RowTemplateResolver` already serves for Friends/Squelch/
|
||||||
|
Fellowship rows. Slice 1's probe reads the live values.
|
||||||
|
- `gmMapUI::AddMapNote @0x004a1bb0` (pc:171967): `CreateChildElement(mgr,
|
||||||
|
m_pMap, layoutDesc, templateElementId)` → `MoveTo(info.X, info.Y)` →
|
||||||
|
`ResizeTo(info.Width, info.Height)` → `SetTooltip(child, info.Name)` (a
|
||||||
|
**literal** wide string, `StringInfo::SetLiteralValue` — not a DAT string
|
||||||
|
table lookup). acdream's `UiElement.AuthoredTooltipText` +
|
||||||
|
`AuthoredTooltipEnabled`, served by `RetailTooltipPresenter`
|
||||||
|
(`src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs`), is the exact
|
||||||
|
seam — set both fields on the built child and the existing tooltip
|
||||||
|
pipeline does the rest. **This closes the one remaining item of register
|
||||||
|
row TS-85** (`docs/architecture/retail-divergence-register.md`), which
|
||||||
|
explicitly named `gmMapUI::AddMapNote @0x004A1C51` as the last unported
|
||||||
|
`SetTooltip` call site.
|
||||||
|
- `gmMapUI::PlaceMarkerOnMap @0x004a18b0` (pc:171827): **CORRECTED
|
||||||
|
2026-08-17 (night-round review, finding F1) — the "accepted as-is"
|
||||||
|
reading below was WRONG.** The BN pseudo-C's operand-less `_ftol2()`
|
||||||
|
calls are not just "argument-passing mangled" — they swallow the
|
||||||
|
ENTIRE FPU chain (constants, multiplies, the Y-axis FSUBR flip), not
|
||||||
|
merely the `arg3`/`arg4` reads. A direct capstone disassembly of the
|
||||||
|
raw bytes at `0x004a18b0` in the PDB-paired `acclient.exe` recovers the
|
||||||
|
true formula: retail projects the AC display coordinate (`x`/`y`,
|
||||||
|
range ≈ ±102.4) onto the marker-area rect via a fixed-point-style
|
||||||
|
transform, not a raw pixel offset:
|
||||||
|
`X = m_x0 - w/2 - (int)((m_x1-m_x0+1) * (x*10+1024) * (-1/2048))`,
|
||||||
|
`Y = m_y0 - h/2 - (int)((m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048))`,
|
||||||
|
then `SetVisible(1)`. Constants read from `.rdata`: `0x79bac8`=10.0,
|
||||||
|
`0x7aac78`=1024.0, `0x7aac70`=-1/2048, `0x7aac68`=2047.0. `w`/`h` are
|
||||||
|
`UIRegion::GetWidth`/`GetHeight` halved by INTEGER (truncating)
|
||||||
|
division, matching retail's `cdq;sub;sar` idiom. Golden case: marker
|
||||||
|
area (6,8)-(247,258), 10x10 icon, position 0.0N/0.0E → (122,128)
|
||||||
|
center — reproduced exactly. Ported at
|
||||||
|
`src/AcDream.App/UI/Layout/MapPageController.cs`'s `PlaceMarker`. The
|
||||||
|
ORIGINAL (wrong) note, kept for the historical record of how the
|
||||||
|
mistake happened: "`MoveTo(m_x0 + (int)x - width/2, m_y0 + (int)y -
|
||||||
|
height/2)`... the x87/FPU argument-passing is BN-mangled in the raw
|
||||||
|
decomp (the `_ftol2()` placeholder swallows the actual `arg3`/`arg4`
|
||||||
|
reads) — this formula is the handoff's own already-verified reading
|
||||||
|
and is accepted as-is; the underlying `+x-w/2` / `+y-h/2` centering
|
||||||
|
pattern is unambiguous from the surrounding integer math." It was not
|
||||||
|
unambiguous — the BN elision hid a whole coordinate-projection
|
||||||
|
transform behind what looked like a plain pixel add.
|
||||||
|
- `gmMapUI::Update @0x004a1eb0` (pc:172084): re-arms `m_nextUpdate =
|
||||||
|
Timer::cur_time + 5.0` every call (5 s cadence, driven by
|
||||||
|
`ListenToGlobalMessage`'s `arg2==3` tick case). Date/time block: builds
|
||||||
|
`"Date: %s\nTime: %s"` from `GameTime::GetDateTimeString`, only calls
|
||||||
|
`SetText` when the string actually differs (a change-detect, not a
|
||||||
|
re-stamp every 5 s). Coordinate block, gated on
|
||||||
|
`CPlayerSystem::IsOutside()`:
|
||||||
|
- **outside**: `CPlayerSystem::InqPlayerCoords` → sign-based N/S/E/W
|
||||||
|
selection (heavily FPU-mangled — BN elides the actual printf format
|
||||||
|
string behind a `Formatted`/vtable-slot placeholder it cannot resolve;
|
||||||
|
accepted as genuinely unrecoverable from this dump, matching the
|
||||||
|
handoff's own UNKNOWN #3) → `SetText` (change-detected) →
|
||||||
|
`PlaceMarkerOnMap(m_pPlayerLocationIcon, x, y)`.
|
||||||
|
- **inside**: `SetText` to a fixed narrow-string constant (also
|
||||||
|
BN-mangled/unrecovered — treated as "empty/blank", matching retail's
|
||||||
|
known behavior of clearing the readout) → `m_pPlayerLocationIcon->
|
||||||
|
SetVisible(0)`.
|
||||||
|
- House marker (independent of the outside/inside branch, gated on
|
||||||
|
`m_pHouseLocationIcon != 0`): `Position::IsValid(&m_HousePosition)` →
|
||||||
|
if invalid, `SetVisible(0)`; if valid,
|
||||||
|
`Position::get_outside_cell_id(&m_HousePosition)` →
|
||||||
|
`LandDefs::gid_to_lcoord` → **the identical** `(v - 0x400) * 0.1 + 0.5`
|
||||||
|
transform on both axes → `PlaceMarkerOnMap(m_pHouseLocationIcon, x, y)`.
|
||||||
|
- `CPlayerSystem::InqPlayerCoords @0x00560090` (pc:364615): confirms the
|
||||||
|
`(lcoord - 0x400) * 0.1 + 0.5` display transform per axis, fed by
|
||||||
|
`CPhysicsObj::get_landscape_coord`. Which of that function's two raw
|
||||||
|
outputs maps to which InqPlayerCoords axis is ambiguous in the BN
|
||||||
|
decomp (a `esp+0x10`/`esp+0x24` swap that can't be resolved without
|
||||||
|
disassembly); **not chased** — see "Precision decision" below.
|
||||||
|
- `LandDefs::gid_to_lcoord @0x00497a90` (pc:163500): clean, no FPU noise.
|
||||||
|
**Already ported** at `src/AcDream.Core/Physics/LandDefs.cs:73`
|
||||||
|
(`LandDefs.GidToLcoord`, issue #106, cross-checked against ACE) — the
|
||||||
|
`edx_2 < 0x100` low-word check that looked suspicious in the raw BN text
|
||||||
|
is exactly the existing port's own documented finding (`low = cellId &
|
||||||
|
0xFFFF; if (low >= 0x100) return false` — a 16-bit sub-register access
|
||||||
|
BN renders as a full-width compare). **No re-port needed**, per the
|
||||||
|
WorldBuilder-inventory doctrine ("read the inventory FIRST... re-porting
|
||||||
|
when we already have a tested port is how bugs slip in") extended here
|
||||||
|
to the equivalent Core-physics precedent.
|
||||||
|
- Also present but **out of scope**: `gmMapUI::ListenToElementMessage
|
||||||
|
@0x004a2350` idMessage `0x1c` handles a GM-only ("`PlayerDesc::
|
||||||
|
PlayerIsPSR`") click-to-teleport on the map (`lcoord_to_gid` from the
|
||||||
|
click pixel → `Position` → presumably a teleport notice). Not part of
|
||||||
|
any assigned slice; flagged for a future issue if wanted, not filed
|
||||||
|
given no immediate need.
|
||||||
|
|
||||||
|
### Precision decision (player marker)
|
||||||
|
|
||||||
|
`InqPlayerCoords`' raw inputs come from `CPhysicsObj::get_landscape_coord`,
|
||||||
|
which is not itself ported and whose BN decomp is FPU-mangled beyond safe
|
||||||
|
recovery tonight. At this map's scale (53-entry pixel-rect town table
|
||||||
|
covering the FULL Dereth landmass, `lcoord` range `[0, 0x7F8)=2040` mapped
|
||||||
|
to ~0.1 map-units/cell, i.e. ~0.8 map-units per landblock, well under a
|
||||||
|
pixel) integer landcell precision is visually indistinguishable from
|
||||||
|
sub-cell precision. The port therefore computes the player marker exactly
|
||||||
|
like the (byte-exact, unambiguous) house marker: current outdoor cell id →
|
||||||
|
`LandDefs.GidToLcoord` → the same `(v-0x400)*0.1+0.5` transform. This is a
|
||||||
|
reasoned substitution of an ALREADY-VERIFIED equivalent primitive, not a
|
||||||
|
guess — flagged here and in a divergence-register row for the one case
|
||||||
|
where it could matter (crossing a cell boundary at the exact map-rendering
|
||||||
|
threshold), not for the sub-pixel precision itself.
|
||||||
|
|
||||||
|
## Wire byte layout — `Position` (used by `HouseData.Position`)
|
||||||
|
|
||||||
|
`references/ACE/Source/ACE.Server/Network/Structure/AllegianceHierarchy.cs:192-212`
|
||||||
|
writes `Cell(uint32) + Pos.XYZ(float×3) + Rotation.WXYZ(float×4)` = 32
|
||||||
|
bytes. acdream already has this exact shape as
|
||||||
|
`AcDream.Core.Net.Messages.CreateObject.ServerPosition` (`CreateObject.cs:479`,
|
||||||
|
parsed at `CreateObject.cs:611-619`) — reused rather than re-defined.
|
||||||
|
|
||||||
|
## House — `gmHouseUI`, decompiled
|
||||||
|
|
||||||
|
- `gmHouseUI::PostInit @0x004a2710` (pc:172581): resolves
|
||||||
|
`m_pTextBox=0x100001e6` (`UIElement_ListBox`, DynamicCast 5). Registers
|
||||||
|
FOUR notice handlers: `0x4dd225` (HouseData), `0x4dd226` (HouseStatus),
|
||||||
|
`0x4dd227` (UpdateRentTime), `0x4dd228` (UpdateRentPayment) — matching
|
||||||
|
wire opcodes `0x0225-0x0228`.
|
||||||
|
- `gmHouseUI::GetHouseLocation @0x004a27b0`: reads `m_pHouseData` at
|
||||||
|
offset `0x74` (an enum, `== 4` is a short-circuit "no location" case) or
|
||||||
|
falls through to `Position::IsValid(&m_pHouseData->m_pos /*+0x2c*/)` →
|
||||||
|
`LandDefs::gid_to_lcoord(Position::get_outside_cell_id(...))`.
|
||||||
|
- Seven `Display*` line builders (`DisplayBuyPayment`, `DisplayRentPayment`,
|
||||||
|
`DisplayBuyTime`, `DisplayRentTimes`, `DisplayLocation`,
|
||||||
|
`DisplayWarningText`, `DisplayPurchaseTimeText`), all called in sequence
|
||||||
|
from `DisplayHouseData @0x004a3380` and from both `Update` overloads.
|
||||||
|
Each is dozens-to-a-few-hundred lines of heavily FPU/string-mangled BN
|
||||||
|
pseudo-C (PStringBase sprintf chains, HousePaymentList iteration,
|
||||||
|
`IsPaidInFull`/`ConstructRentWarningMessage`-style formatting). **Sized
|
||||||
|
as genuinely disproportionate for tonight's batch** — this matches the
|
||||||
|
task brief's own pre-authorized fallback ("if the whole owned-house wire
|
||||||
|
half balloons beyond reach tonight, land the default-content tab + the
|
||||||
|
enum/parser groundwork, and file the remainder as a precise ISSUES
|
||||||
|
entry"). Decision: land the mount (default authored content, zero wire)
|
||||||
|
and the wire groundwork (enum route registration + parsers +
|
||||||
|
`RuntimeHouseState` raw-field owner), and file the seven line builders
|
||||||
|
as an ISSUES entry rather than porting them tonight.
|
||||||
|
|
||||||
|
## Wire — GameEventType already has all four ids (corrects the handoff)
|
||||||
|
|
||||||
|
The handoff claimed "0x0227/0x0228 absent from the enum". Checked
|
||||||
|
`src/AcDream.Core.Net/Messages/GameEventType.cs:73-76` directly — **all
|
||||||
|
four are already defined**:
|
||||||
|
|
||||||
|
```
|
||||||
|
HouseData = 0x0225
|
||||||
|
HouseStatus = 0x0226
|
||||||
|
UpdateRentTime = 0x0227
|
||||||
|
UpdateRentPayment= 0x0228
|
||||||
|
```
|
||||||
|
|
||||||
|
What's actually missing (confirmed by grepping `src/` for every
|
||||||
|
`GameEventType.House*`/`UpdateRent*` reference: zero hits) is **routing** —
|
||||||
|
no parser, no `GameEventWiring` registration, no consumer. ACE's own
|
||||||
|
writers for two of the four are themselves stubs worth knowing about
|
||||||
|
before treating any live capture as ground truth:
|
||||||
|
`GameEventHouseUpdateRentTime.cs` always writes a hardcoded `rentTime =
|
||||||
|
0u`; `GameEventHouseUpdateRentPayment.cs` always writes an empty
|
||||||
|
`List<HousePayment>`. `GameEventHouseData`/`GameEventHouseStatus` write
|
||||||
|
real data (`HouseData`/`(uint)WeenieError`).
|
||||||
|
|
||||||
|
## Marker table — `s_rgLocations[0x35]` (53 entries), verbatim
|
||||||
|
|
||||||
|
`docs/research/named-retail/acclient_2013_pseudo_c.txt:977225-977651`.
|
||||||
|
Struct `gmMapUI::LocationRolloverInfo { uint X,Y,Width,Height; wchar_t*
|
||||||
|
Name; }` (`acclient.h:55686`). Values are direct pixel rects passed to
|
||||||
|
`MoveTo`/`ResizeTo` on `m_pMap` — no coordinate transform (unlike the
|
||||||
|
player/house markers). Ported verbatim into a static C# array (see
|
||||||
|
`MapLocations.cs` below) — town list cross-checked as the complete classic
|
||||||
|
Dereth town set (Holtburg, Arwic, Yaraq, Shoushi, Rithwic, Samsur, Zaikhal,
|
||||||
|
Xarabydun, Yanshi, Nanto, Kara, Lin, Mayoi, Baishi, Sawato, Tou-Tou,
|
||||||
|
Al-Jalima, Al-Arqas, Qalaba'r, Silyun, Bandit Castle, Fort Tethana, Glenden
|
||||||
|
Wood, Cragstone, Dryreach, Eastham, Lytelthorpe, MacNiall's Freehold,
|
||||||
|
Linvak Tukal, Uziz, Wai Jhou, Timaru, Sanamar, Stonehold, Redspire,
|
||||||
|
Bluespire, Greenspire, Neydisa, Mt Esper-Crater Village, Plateau Village,
|
||||||
|
Fiun Outpost, Danby's Outpost, Candeth Keep, Khayyaban, Kryst, Hebian-to,
|
||||||
|
Oolutanga's Refuge, Ulgrim's Island, Ayan Baqur, Aerlinthe Island,
|
||||||
|
Singularity Caul Island).
|
||||||
|
|
||||||
|
## Seams reused (no new infrastructure needed)
|
||||||
|
|
||||||
|
- Panel mount recipe: `RetailUiRuntime.MountSocialPanel`
|
||||||
|
(`src/AcDream.App/UI/RetailUiRuntime.cs:3033-3257`) and
|
||||||
|
`Layout.SocialPanelController.cs` — copied for a 2-tab
|
||||||
|
`MapHousePanelController`.
|
||||||
|
- `UiTabPanel` (`src/AcDream.App/UI/UiTabPanel.cs`) — same tab-table/
|
||||||
|
`ActivateTabBehavior` mechanism.
|
||||||
|
- `ToolbarController` + `RetailPanelCatalog` — add panel id 16 to both
|
||||||
|
`Mounted` and `Toolbar` arrays; button un-ghosts automatically once its
|
||||||
|
panel id resolves via `TryGetWindowName`.
|
||||||
|
- `RetailTooltipPresenter` via `UiElement.AuthoredTooltipText`/
|
||||||
|
`AuthoredTooltipEnabled` — town marker tooltips, closing TS-85's last
|
||||||
|
item.
|
||||||
|
- `Layout.RowTemplateResolver` pattern — the per-town hotspot child is a
|
||||||
|
template-instantiated element exactly like Friends/Squelch/Fellowship
|
||||||
|
rows (template LayoutDesc/element resolved from `m_pMap`'s own `0x48`/
|
||||||
|
`0x47` attrs).
|
||||||
|
- `LandDefs.GidToLcoord`/`LcoordToGid` (`src/AcDream.Core/Physics/LandDefs.cs`)
|
||||||
|
— coordinate math, already ported and tested (issue #106).
|
||||||
|
- `ServerPosition` (`src/AcDream.Core.Net/Messages/CreateObject.cs:479`) —
|
||||||
|
House wire's `Position` field, already parsed elsewhere.
|
||||||
|
- `WorldTimeService.CurrentCalendar` / `DerethDateTime` — calendar data;
|
||||||
|
only a formatter matching retail's `"Date: %s\nTime: %s"` shape is new.
|
||||||
|
- `RuntimeTradeState` (`src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs`)
|
||||||
|
— J4.x-style session-scoped owner pattern, template for
|
||||||
|
`RuntimeHouseState` if the wire groundwork lands.
|
||||||
|
|
||||||
|
## Open items carried into slice reports
|
||||||
|
|
||||||
|
1. Live probe (slice 1) must re-confirm the desk-verified slot/panelId/
|
||||||
|
button facts above against the ACTUAL live DAT install, not just trust
|
||||||
|
the committed fixture + FA doc (which are consistent with each other
|
||||||
|
but both need the live cross-check the task mandates).
|
||||||
|
2. `m_pMap`'s attrs `0x47`/`0x48` (hotspot template element/LayoutDesc)
|
||||||
|
need a live read — the decomp explains their MEANING but not their
|
||||||
|
VALUE.
|
||||||
|
3. House ListBox `0x100001e6`'s authored default content (the "You do not
|
||||||
|
currently own a house" text) needs a live read to confirm it's baked
|
||||||
|
into the LayoutDesc rather than something the client synthesizes.
|
||||||
|
4. Coordinate/N-S-E-W format string for the outside coordinate readout is
|
||||||
|
unrecoverable from this decomp dump (BN elides it behind an
|
||||||
|
unresolved vtable-slot placeholder) — implemented per the task's
|
||||||
|
pre-authorized fallback (sign-based N/S/E/W, one-decimal magnitude,
|
||||||
|
`"42.1N, 33.6E"` shape), marked inferred-pending-verification in code.
|
||||||
|
5. Seven House `Display*` line builders deferred to an ISSUES entry (see
|
||||||
|
above) — ownership of the wire groundwork (parsers, `RuntimeHouseState`
|
||||||
|
raw fields) still lands this session so a future session can pick up
|
||||||
|
the text formatting without re-doing the wire.
|
||||||
|
6. Toolbar button `0x10000197` (panel id 12 = Social) appears to be a
|
||||||
|
real, currently-ghosted toolbar entry for the Social panel — contradicts
|
||||||
|
`SocialPanelController`'s class doc ("No toolbar button authors this
|
||||||
|
id"). Flagged, not chased — outside Batch C's scope.
|
||||||
|
|
@ -79,7 +79,15 @@ internal sealed record InteractionRetainedUiDependencies(
|
||||||
Func<double> ClientTime,
|
Func<double> ClientTime,
|
||||||
Action<string> Log,
|
Action<string> Log,
|
||||||
AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice,
|
AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice,
|
||||||
ICurrentGpuFrameSource GpuFrameSource)
|
ICurrentGpuFrameSource GpuFrameSource,
|
||||||
|
// Batch C (Map/House toolbar panel): the same shape as ClientTime above —
|
||||||
|
// GameWindow's WorldTimeService is a stable for-the-window-lifetime
|
||||||
|
// service (unlike the per-session entity/world state Radar's deferred
|
||||||
|
// slots exist for), so a direct closure is enough; no DeferredXSource
|
||||||
|
// needed. gmMapUI::Update @0x004a1eb0 reads GameTime::current_game_time
|
||||||
|
// every 5s — MapPageController owns that cadence, this just supplies the
|
||||||
|
// current reading.
|
||||||
|
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar)
|
||||||
{
|
{
|
||||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||||
|
|
||||||
|
|
@ -970,6 +978,24 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
AllegianceSetUpdateSubscription: on =>
|
AllegianceSetUpdateSubscription: on =>
|
||||||
late.GameRuntime.AllegianceSetUpdateSubscription(on),
|
late.GameRuntime.AllegianceSetUpdateSubscription(on),
|
||||||
Trade: d.Runtime.Trade),
|
Trade: d.Runtime.Trade),
|
||||||
|
// Batch C (overnight hover/UI round, 2026-08-17): HouseLines
|
||||||
|
// now wired to the minimal RuntimeHouseState owner (see its
|
||||||
|
// class doc) — HousePosition (the Map tab's house marker) is
|
||||||
|
// deferred to #413's remaining owned-house work, since it
|
||||||
|
// needs HouseData's Position field, not yet consumed here.
|
||||||
|
// Night-round review F2: the tab-open HouseShown ->
|
||||||
|
// SendHouseQuery trigger (former AD-107) is REMOVED — retail
|
||||||
|
// sends HouseQuery once, unconditionally, at
|
||||||
|
// CM_House::Event_QueryHouse @0x006aaa00 (tail-called from
|
||||||
|
// CPlayerSystem::InitializePlayer's login-complete path), not
|
||||||
|
// on House-tab activation; neither gmHouseUI::PostInit nor
|
||||||
|
// gmMapUI::PostInit sends one on tab-open. HouseShown now
|
||||||
|
// defaults to null (HousePageController.OnShown's
|
||||||
|
// _bindings.OnShown?.Invoke() no-ops).
|
||||||
|
MapHouse: new MapHouseRuntimeBindings(
|
||||||
|
CurrentCalendar: d.CurrentCalendar,
|
||||||
|
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
|
||||||
|
HouseLines: () => d.Runtime.HouseOwner.Lines),
|
||||||
StackSplitQuantity: d.StackSplitQuantity,
|
StackSplitQuantity: d.StackSplitQuantity,
|
||||||
Plugins: d.UiRegistry,
|
Plugins: d.UiRegistry,
|
||||||
Persistence: persistence,
|
Persistence: persistence,
|
||||||
|
|
|
||||||
|
|
@ -327,14 +327,28 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
(text, type) => _domain.Communication.AddText(text, type),
|
(text, type) => _domain.Communication.AddText(text, type),
|
||||||
Fellowship: _domain.Runtime.FellowshipOwner,
|
Fellowship: _domain.Runtime.FellowshipOwner,
|
||||||
Allegiance: _domain.Runtime.AllegianceOwner,
|
Allegiance: _domain.Runtime.AllegianceOwner,
|
||||||
Trade: _domain.Runtime.TradeOwner));
|
Trade: _domain.Runtime.TradeOwner,
|
||||||
|
House: _domain.Runtime.HouseOwner));
|
||||||
return new GraphicalSessionEventRoute(
|
return new GraphicalSessionEventRoute(
|
||||||
route,
|
route,
|
||||||
_domain.Runtime,
|
_domain.Runtime,
|
||||||
_world.PlacementProjection,
|
_world.PlacementProjection,
|
||||||
_world.PlacementRetries,
|
_world.PlacementRetries,
|
||||||
_world.FirstEntryDrive,
|
_world.FirstEntryDrive,
|
||||||
_ => session.SendGameAction(GameActionLoginComplete.Build()),
|
_ =>
|
||||||
|
{
|
||||||
|
session.SendGameAction(GameActionLoginComplete.Build());
|
||||||
|
// Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00
|
||||||
|
// is tail-called, unconditionally, from the end of
|
||||||
|
// CPlayerSystem::InitializePlayer @0x00563570 — the SAME
|
||||||
|
// once-per-session function AttemptSendLoginCompleteNotification
|
||||||
|
// lives in (guarded by player_initialized), right after that
|
||||||
|
// notification. This is the graphical host's direct
|
||||||
|
// (non-portal) first-entry completion edge — the exact
|
||||||
|
// analogue. Portal-space re-entries (LocalPlayerTeleportController)
|
||||||
|
// do NOT resend it, matching retail's single-shot guard.
|
||||||
|
session.SendHouseQuery();
|
||||||
|
},
|
||||||
_world.AcceptedPositionDrive,
|
_world.AcceptedPositionDrive,
|
||||||
_world.RemotePlacementDrive);
|
_world.RemotePlacementDrive);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,128 @@ internal static class RetailSkillFormula
|
||||||
_ => result,
|
_ => result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>SkillSystem::InqAttributeName @ 0x005c8d90</c> — the six
|
||||||
|
/// hardcoded attribute display names (matched exactly against
|
||||||
|
/// <c>DatReaderWriter.Enums.AttributeId</c>'s Strength=1..Self=6
|
||||||
|
/// numbering, the same table <see cref="ResolveAttribute"/>-style
|
||||||
|
/// switches elsewhere in this file already assume).
|
||||||
|
/// </summary>
|
||||||
|
public static string AttributeName(DatReaderWriter.Enums.AttributeId attribute) => attribute switch
|
||||||
|
{
|
||||||
|
DatReaderWriter.Enums.AttributeId.Strength => "Strength",
|
||||||
|
DatReaderWriter.Enums.AttributeId.Endurance => "Endurance",
|
||||||
|
DatReaderWriter.Enums.AttributeId.Quickness => "Quickness",
|
||||||
|
DatReaderWriter.Enums.AttributeId.Coordination => "Coordination",
|
||||||
|
DatReaderWriter.Enums.AttributeId.Focus => "Focus",
|
||||||
|
DatReaderWriter.Enums.AttributeId.Self => "Self",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>SkillSystem::InqSkillFormula @ 0x005c89b0</c> — builds the
|
||||||
|
/// human-readable formula line shown in a skill's tooltip, e.g.
|
||||||
|
/// <c>"( (Strength + Coordination) / 2 )"</c>, <c>"( Quickness )"</c>, or
|
||||||
|
/// <c>"( (2 x Quickness) )"</c>. Ported byte-for-byte from the retail
|
||||||
|
/// binary's string pool: the five short literal fragments below
|
||||||
|
/// (<c>data_7e7930</c> = <c>" )"</c>, <c>data_7e7934</c> = <c>"+%u"</c>,
|
||||||
|
/// <c>data_7e7940</c> = <c>" + "</c>, <c>data_7e7950</c> = <c>"("</c>,
|
||||||
|
/// <c>data_7e7954</c> = <c>"( "</c>, <c>data_797584</c> = <c>")"</c>)
|
||||||
|
/// sit between two vtable declarations in the pseudo-C dump and Binary
|
||||||
|
/// Ninja's type inference never recognized them as strings, so they show
|
||||||
|
/// up unlabeled rather than as readable literals — this port decoded
|
||||||
|
/// their raw bytes directly as narrow ASCII (the function operates
|
||||||
|
/// exclusively on <c>AC1Legacy::PStringBase<char></c>, so 1
|
||||||
|
/// byte/char, not the 2-byte/char wide encoding used elsewhere in this
|
||||||
|
/// file's neighborhood). <c>" / %u"</c> (divisor) and <c>"(%u x %s)"</c>
|
||||||
|
/// (multiplier wrap) are plain, directly-visible literals in the same
|
||||||
|
/// function and needed no such recovery. Returns null when the skill has
|
||||||
|
/// neither attribute wired (<c>_x < 1 || _attr1 == 0</c> AND the
|
||||||
|
/// attr2 equivalent), matching <c>InqSkillFormula</c>'s own false
|
||||||
|
/// return.
|
||||||
|
/// </summary>
|
||||||
|
public static string? FormatFormula(SkillFormula formula)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(formula);
|
||||||
|
|
||||||
|
// F13 (night-round review): read the SAME unsigned reinterpretation
|
||||||
|
// TryCalculate above uses — this class's own doc comment already
|
||||||
|
// states the invariant ("DAT reader fields are signed storage
|
||||||
|
// views... deliberately reinterpreted as retail's unsigned W/X/Y/Z
|
||||||
|
// words") but this method previously read the raw signed int
|
||||||
|
// fields directly. A high-bit-set multiplier/divisor/bonus would
|
||||||
|
// both mis-gate hasAttr1/hasAttr2 (reads negative, failing the
|
||||||
|
// >= 1 check TryCalculate's unsigned reinterpretation would have
|
||||||
|
// passed) and print the wrong (negative) number — out of sync with
|
||||||
|
// the value TryCalculate actually computes with for the SAME
|
||||||
|
// formula.
|
||||||
|
uint x = unchecked((uint)formula.Attribute1Multiplier);
|
||||||
|
uint y = unchecked((uint)formula.Attribute2Multiplier);
|
||||||
|
uint w = unchecked((uint)formula.AdditiveBonus);
|
||||||
|
uint divisor = unchecked((uint)formula.Divisor);
|
||||||
|
|
||||||
|
bool hasAttr1 = x >= 1 && formula.Attribute1 != 0;
|
||||||
|
bool hasAttr2 = y >= 1 && formula.Attribute2 != 0;
|
||||||
|
if (!hasAttr1 && !hasAttr2)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var text = new System.Text.StringBuilder("( ");
|
||||||
|
if (hasAttr1 && hasAttr2)
|
||||||
|
text.Append('(');
|
||||||
|
|
||||||
|
if (hasAttr1)
|
||||||
|
{
|
||||||
|
string name1 = AttributeName(formula.Attribute1);
|
||||||
|
text.Append(x <= 1
|
||||||
|
? name1
|
||||||
|
: $"({x} x {name1})");
|
||||||
|
if (hasAttr2)
|
||||||
|
text.Append(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAttr2)
|
||||||
|
{
|
||||||
|
string name2 = AttributeName(formula.Attribute2);
|
||||||
|
text.Append(y <= 1
|
||||||
|
? name2
|
||||||
|
: $"({y} x {name2})");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAttr1 && hasAttr2)
|
||||||
|
text.Append(')');
|
||||||
|
if (divisor != 1)
|
||||||
|
text.Append($" / {divisor}");
|
||||||
|
if (w != 0)
|
||||||
|
text.Append($"+{w}");
|
||||||
|
text.Append(" )");
|
||||||
|
return text.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>SkillInfoRegion::GetTooltip @ 0x004f1fe0</c>, called once
|
||||||
|
/// from <c>SkillInfoRegion::SkillInfoRegion @ 0x004f2140</c>'s
|
||||||
|
/// <c>UIElement::SetTooltip</c> at 0x004f222f. Composition is exactly
|
||||||
|
/// <c>"\n" + formula + description</c> — retail concatenates the
|
||||||
|
/// description directly onto the formula line with NO separator between
|
||||||
|
/// them (ported verbatim, not "fixed": <c>append_n_chars</c> runs
|
||||||
|
/// immediately after the formula assignment with no intervening
|
||||||
|
/// literal). <c>SkillSystem::InqSkillDescription @ 0x005c8770</c> reads
|
||||||
|
/// <c>SkillBase._description</c> — the same DAT field
|
||||||
|
/// <see cref="DatReaderWriter.Types.SkillBase.Description"/> already
|
||||||
|
/// exposes, so no hand-transcription was needed for the ~30+ skill
|
||||||
|
/// description strings (unlike the six hardcoded attribute
|
||||||
|
/// descriptions).
|
||||||
|
/// </summary>
|
||||||
|
public static string? BuildTooltip(SkillBase skillBase)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(skillBase);
|
||||||
|
|
||||||
|
string? formula = FormatFormula(skillBase.Formula);
|
||||||
|
string description = skillBase.Description.Value ?? string.Empty;
|
||||||
|
string tooltip = (formula is null ? string.Empty : "\n" + formula) + description;
|
||||||
|
return tooltip.Length == 0 ? null : tooltip;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -1419,7 +1419,8 @@ public sealed class GameWindow :
|
||||||
ClientTimerNow,
|
ClientTimerNow,
|
||||||
Console.WriteLine,
|
Console.WriteLine,
|
||||||
hostInputCamera.GpuDevice,
|
hostInputCamera.GpuDevice,
|
||||||
hostInputCamera.GpuFrameLifetime),
|
hostInputCamera.GpuFrameLifetime,
|
||||||
|
() => WorldTime.CurrentCalendar),
|
||||||
_retailUiLease,
|
_retailUiLease,
|
||||||
this).Compose(
|
this).Compose(
|
||||||
platformResult,
|
platformResult,
|
||||||
|
|
|
||||||
|
|
@ -228,4 +228,11 @@ public sealed record CharacterSkill(
|
||||||
// retail SkillInfoRegion::GetVitaeModifier (0x004f0fa0). Used for the
|
// retail SkillInfoRegion::GetVitaeModifier (0x004f0fa0). Used for the
|
||||||
// footer-title vitae-specific parenthetical, separate from the buff delta
|
// footer-title vitae-specific parenthetical, separate from the buff delta
|
||||||
// (CurrentLevel − VitaeModifier − BaseLevel).
|
// (CurrentLevel − VitaeModifier − BaseLevel).
|
||||||
int VitaeModifier = 0);
|
int VitaeModifier = 0,
|
||||||
|
// TS-85 (character-panel tooltips): retail SkillInfoRegion::GetTooltip
|
||||||
|
// (0x004f1fe0), composed once at row construction — formula line + skill
|
||||||
|
// description, DAT-sourced via SkillBase.Description/Formula
|
||||||
|
// (RetailSkillFormula.BuildTooltip). Null when the DAT SkillTable had no
|
||||||
|
// entry for this skill (fallback-named skills) or GetTooltip would have
|
||||||
|
// produced empty text.
|
||||||
|
string? TooltipText = null);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.Net;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Player;
|
using AcDream.Core.Player;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
|
|
@ -382,6 +383,10 @@ public sealed class CharacterSheetProvider
|
||||||
int specializedCost = skillBase?.SpecializedCost ?? 0;
|
int specializedCost = skillBase?.SpecializedCost ?? 0;
|
||||||
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
|
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
|
||||||
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
|
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
|
||||||
|
// TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0) — formula line +
|
||||||
|
// DAT description, composed once here (matches retail's once-at-
|
||||||
|
// construction SetTooltip; the row never recomputes it per frame).
|
||||||
|
string? tooltipText = skillBase is null ? null : RetailSkillFormula.BuildTooltip(skillBase);
|
||||||
|
|
||||||
// Issue #267: CurrentLevel is the EFFECTIVE (vitae + buff) level —
|
// Issue #267: CurrentLevel is the EFFECTIVE (vitae + buff) level —
|
||||||
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
|
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
|
||||||
|
|
@ -406,7 +411,8 @@ public sealed class CharacterSheetProvider
|
||||||
specializedCost,
|
specializedCost,
|
||||||
raiseCost,
|
raiseCost,
|
||||||
raise10Cost,
|
raise10Cost,
|
||||||
values.VitaeModifier));
|
values.VitaeModifier,
|
||||||
|
tooltipText));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,43 @@ public static class CharacterStatController
|
||||||
("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6
|
("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttributeDescription
|
||||||
|
/// @ 0x005c8e30</c> — six hardcoded strings, byte-decoded from the retail binary's
|
||||||
|
/// string pool (the pseudo-C dump truncates them with "…"). Ported from
|
||||||
|
/// <c>AttributeInfoRegion::AttributeInfoRegion @ 0x004f1530</c>'s
|
||||||
|
/// <c>UIElement::SetTooltip</c> call at 0x004f1617, keyed by retail attribute id
|
||||||
|
/// (matches <see cref="AttrRows"/>' statId column, NOT the array index — the
|
||||||
|
/// authored row order swaps Coordination/Quickness relative to the id numbering).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyDictionary<uint, string> AttributeDescriptions =
|
||||||
|
new Dictionary<uint, string>
|
||||||
|
{
|
||||||
|
[1u] = "Measures your character's muscular power.", // Strength
|
||||||
|
[2u] = "Measures how healthy your character is.", // Endurance
|
||||||
|
[3u] = "Measures how fast your character is.", // Quickness
|
||||||
|
[4u] = "Measures your character's reflexes", // Coordination (no trailing period — verified byte-exact)
|
||||||
|
[5u] = "Measures your character's mind and senses.", // Focus
|
||||||
|
[6u] = "Measures your character's willpower.", // Self
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttribute2ndDescription
|
||||||
|
/// @ 0x005c8f70</c> — three hardcoded strings shared by each Max/Current pair (1&2,
|
||||||
|
/// 3&4, 5&6), byte-decoded from the retail string pool. Ported from
|
||||||
|
/// <c>Attribute2ndInfoRegion::Attribute2ndInfoRegion @ 0x004f1680</c>'s
|
||||||
|
/// <c>UIElement::SetTooltip</c> call at 0x004f1777, keyed by <see cref="VitalRows"/>'
|
||||||
|
/// maxStatId column (1/3/5 — either member of the pair resolves the same text in
|
||||||
|
/// retail).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyDictionary<uint, string> Attribute2ndDescriptions =
|
||||||
|
new Dictionary<uint, string>
|
||||||
|
{
|
||||||
|
[1u] = "(Endurance/2)\nIf you run out of health, you will die!", // Health
|
||||||
|
[3u] = "(Endurance)\nAffects your actions and movement.", // Stamina
|
||||||
|
[5u] = "(Self)\nAffects how much magic you can cast.", // Mana
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
|
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
|
||||||
/// and raise buttons in <paramref name="layout"/> to <paramref name="data"/>.
|
/// and raise buttons in <paramref name="layout"/> to <paramref name="data"/>.
|
||||||
|
|
@ -671,7 +708,7 @@ public static class CharacterStatController
|
||||||
|
|
||||||
for (int i = 0; i < AttrRows.Length; i++)
|
for (int i = 0; i < AttrRows.Length; i++)
|
||||||
{
|
{
|
||||||
var (rowName, iconDid, _) = AttrRows[i];
|
var (rowName, iconDid, statId) = AttrRows[i];
|
||||||
int rowIndex = i;
|
int rowIndex = i;
|
||||||
|
|
||||||
var row = AddRow(list, datFont, spriteResolve,
|
var row = AddRow(list, datFont, spriteResolve,
|
||||||
|
|
@ -694,6 +731,7 @@ public static class CharacterStatController
|
||||||
return v.ToString();
|
return v.ToString();
|
||||||
},
|
},
|
||||||
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
|
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
|
||||||
|
row.TooltipText = AttributeDescriptions.GetValueOrDefault(statId);
|
||||||
|
|
||||||
row.OnClick = () =>
|
row.OnClick = () =>
|
||||||
{
|
{
|
||||||
|
|
@ -706,7 +744,7 @@ public static class CharacterStatController
|
||||||
|
|
||||||
for (int i = 0; i < VitalRows.Length; i++)
|
for (int i = 0; i < VitalRows.Length; i++)
|
||||||
{
|
{
|
||||||
var (rowName, iconDid, _) = VitalRows[i];
|
var (rowName, iconDid, maxStatId) = VitalRows[i];
|
||||||
int rowIndex = i;
|
int rowIndex = i;
|
||||||
int absIndex = AttrRows.Length + i;
|
int absIndex = AttrRows.Length + i;
|
||||||
|
|
||||||
|
|
@ -726,6 +764,7 @@ public static class CharacterStatController
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
valueColorProvider: () => VitalValueColor(data(), rowIndex));
|
valueColorProvider: () => VitalValueColor(data(), rowIndex));
|
||||||
|
row.TooltipText = Attribute2ndDescriptions.GetValueOrDefault(maxStatId);
|
||||||
|
|
||||||
row.OnClick = () =>
|
row.OnClick = () =>
|
||||||
{
|
{
|
||||||
|
|
@ -785,6 +824,9 @@ public static class CharacterStatController
|
||||||
valueProvider: () => LiveSkill().CurrentLevel.ToString(),
|
valueProvider: () => LiveSkill().CurrentLevel.ToString(),
|
||||||
valueColorProvider: () => SkillValueColor(LiveSkill()),
|
valueColorProvider: () => SkillValueColor(LiveSkill()),
|
||||||
nameColor: Vector4.One);
|
nameColor: Vector4.One);
|
||||||
|
// TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0), stamped once at
|
||||||
|
// row construction — matches retail (never recomputed per frame).
|
||||||
|
row.TooltipText = skill.TooltipText;
|
||||||
row.OnClick = () =>
|
row.OnClick = () =>
|
||||||
{
|
{
|
||||||
HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10);
|
HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10);
|
||||||
|
|
|
||||||
134
src/AcDream.App/UI/Layout/HousePageController.cs
Normal file
134
src/AcDream.App/UI/Layout/HousePageController.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds the House tab of the retail Map/House panel (<c>gmHouseUI</c>,
|
||||||
|
/// class id <c>0x10000025</c>) — <see cref="MapHousePanelController"/>'s
|
||||||
|
/// second page.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail references: <c>gmHouseUI::PostInit @0x004a2710</c> resolves ONE
|
||||||
|
/// <c>UIElement_ListBox</c> (<c>m_pTextBox = 0x100001e6</c>) and registers
|
||||||
|
/// four notice handlers for wire opcodes <c>0x0225-0x0228</c>.
|
||||||
|
/// <c>gmHouseUI::AddHousePanelText @0x004a2810</c> is
|
||||||
|
/// <c>UIElement_ListBox::AddItemFromTemplateList(this, 0, nullptr)</c> —
|
||||||
|
/// live-DAT-confirmed (<c>MapHousePanelSlotProbeTests</c>) as a single
|
||||||
|
/// <c>UIElement_Text</c> (Type 12) row template at LayoutDesc
|
||||||
|
/// <c>0x21000025</c> element <c>0x100001e7</c>, no scrollbar authored. The
|
||||||
|
/// ListBox itself authors ZERO static child rows — the box starts genuinely
|
||||||
|
/// empty until the first server notice populates it (retail's own
|
||||||
|
/// <c>PostInit</c> never calls <c>Update</c>/<c>DisplayHouseData</c>).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Scope — see ISSUES #413 for the full ledger.</b> Batch C
|
||||||
|
/// (2026-08-17) shipped the mount (this class) and the wire PARSING
|
||||||
|
/// groundwork (<c>GameEvents.ParseHouseData</c>/<c>ParseHouseStatus</c>/
|
||||||
|
/// <c>ParseUpdateRentTime</c>/<c>ParseUpdateRentPayment</c>,
|
||||||
|
/// <c>GameEventWiring</c>'s four delegate holes, the outbound HouseQuery
|
||||||
|
/// action). The House-tab ownership-text closer session (also 2026-08-17)
|
||||||
|
/// wired <see cref="Bindings.Lines"/> to the minimal <c>RuntimeHouseState</c>
|
||||||
|
/// owner and ported <c>DisplayPurchaseTimeText @0x004a3110</c>'s expired
|
||||||
|
/// branch — a fresh houseless character's House tab shows the single
|
||||||
|
/// decomp-verified line "You may buy another house immediately.",
|
||||||
|
/// live-connected-gate-verified (screenshot + structural UI-tree dump
|
||||||
|
/// against the real <c>+Acdream</c> character on a local ACE server). The
|
||||||
|
/// other six <c>Display*</c> line builders <c>DisplayHouseData</c> calls
|
||||||
|
/// (owned-house-only content: buy/rent payments and times, location,
|
||||||
|
/// warning text) remain unported — ISSUES #413's surviving scope. The
|
||||||
|
/// night-round review (F2, 2026-08-17) moved the outbound HouseQuery send
|
||||||
|
/// from a House-tab-open trigger to retail's real login-complete edge (see
|
||||||
|
/// <see cref="Bindings.OnShown"/>'s own doc), so by the time a player opens
|
||||||
|
/// the House tab the data has usually already arrived.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HousePageController
|
||||||
|
{
|
||||||
|
public const uint TextBoxId = 0x100001E6u;
|
||||||
|
|
||||||
|
public sealed record Bindings(
|
||||||
|
Func<IReadOnlyList<string>> Lines,
|
||||||
|
// Fires once when the page transitions to visible. Night-round
|
||||||
|
// review F2 (2026-08-17): NOT wired to SendHouseQuery any more —
|
||||||
|
// retail's HouseQuery (0x021E, CM_House::Event_QueryHouse
|
||||||
|
// @0x006aaa00) is byte-decoded confirmed to fire exactly once at the
|
||||||
|
// client's login-complete edge (tail-called, unconditionally, from
|
||||||
|
// CPlayerSystem::InitializePlayer @0x00563570, right after
|
||||||
|
// AttemptSendLoginCompleteNotification — both guarded by the SAME
|
||||||
|
// once-per-session player_initialized flag), never from House-tab
|
||||||
|
// activation; neither gmHouseUI::PostInit nor gmMapUI::PostInit
|
||||||
|
// sends one on tab-open. See WorldSession.SendHouseQuery's own
|
||||||
|
// production call sites (the graphical/headless first-entry-
|
||||||
|
// completion edges) for where it's actually sent now. This hook
|
||||||
|
// remains available for a genuinely page-shown concern, but no
|
||||||
|
// current caller wires it.
|
||||||
|
Action? OnShown = null,
|
||||||
|
// Batch C House-ownership-text closer (2026-08-17): the ListBox's
|
||||||
|
// OWN row template (LayoutDesc 0x21000025 element 0x100001E7,
|
||||||
|
// live-DAT-confirmed by MapHousePanelSlotProbeTests) is resolved
|
||||||
|
// through the SAME generic (templateLayoutId, templateElementId) ->
|
||||||
|
// UiElement seam MapPageController.Bindings.TemplateResolver already
|
||||||
|
// wires for the Map tab's town hotspots — it performs the identical
|
||||||
|
// LayoutImporter.ImportInfos+Build operation, nothing map-specific
|
||||||
|
// about it. Without this, UiTemplateListBox.AddItemFromTemplateList
|
||||||
|
// always returns null (no resolver = no row), so Refresh silently
|
||||||
|
// produced zero rows regardless of Lines — the gap this session
|
||||||
|
// closes alongside the text composition itself.
|
||||||
|
Func<uint, uint, UiElement?>? TemplateResolver = null);
|
||||||
|
|
||||||
|
private readonly UiTemplateListBox _listBox;
|
||||||
|
private readonly Bindings _bindings;
|
||||||
|
private IReadOnlyList<string> _lastLines = Array.Empty<string>();
|
||||||
|
|
||||||
|
private HousePageController(UiTemplateListBox listBox, Bindings bindings)
|
||||||
|
{
|
||||||
|
_listBox = listBox;
|
||||||
|
_bindings = bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HousePageController? Bind(UiElement page, Bindings bindings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(page);
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
|
||||||
|
if (UiElement.FindDescendant(page, TextBoxId) is not UiTemplateListBox listBox)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[D.2b] House tab: ListBox 0x{TextBoxId:X8} not found or not a template list box.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
listBox.TemplateResolver = bindings.TemplateResolver;
|
||||||
|
var controller = new HousePageController(listBox, bindings);
|
||||||
|
controller.Refresh(bindings.Lines());
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-frame poll — cheap no-op when the line set hasn't
|
||||||
|
/// changed (reference-content compare via SequenceEqual, mirroring the
|
||||||
|
/// other social-panel pages' revision-gated rebuild discipline).</summary>
|
||||||
|
public void Tick()
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> lines = _bindings.Lines();
|
||||||
|
if (lines.SequenceEqual(_lastLines)) return;
|
||||||
|
Refresh(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnShown() => _bindings.OnShown?.Invoke();
|
||||||
|
|
||||||
|
private void Refresh(IReadOnlyList<string> lines)
|
||||||
|
{
|
||||||
|
_lastLines = lines;
|
||||||
|
_listBox.Flush();
|
||||||
|
foreach (string line in lines)
|
||||||
|
{
|
||||||
|
UiElement? row = _listBox.AddItemFromTemplateList(0);
|
||||||
|
if (row is UiText text)
|
||||||
|
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/AcDream.App/UI/Layout/MapHousePanelController.cs
Normal file
185
src/AcDream.App/UI/Layout/MapHousePanelController.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mounts retail's two-tab Map/House panel — LayoutDesc <c>0x2100006E</c>
|
||||||
|
/// slot <c>0x1000018C</c>, <see cref="AcDream.App.UI.RetailPanelCatalog"/>
|
||||||
|
/// id <b>16</b>. Batch C (overnight hover/UI round, 2026-08-17), built on
|
||||||
|
/// the <see cref="SocialPanelController"/>/<see cref="OptionsPanelController"/>
|
||||||
|
/// recipe (Type-8 tab host, <see cref="UiTabPanel.ActivateTabBehavior"/>,
|
||||||
|
/// per-page scoped controllers).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Slot/panelId/button — resolved, not guessed.</b> The FA campaign's own
|
||||||
|
/// full 16-slot <c>gmPanelUI::SetupChildren</c> dump
|
||||||
|
/// (<c>docs/research/2026-08-11-fa-panel-structure.md:927-933</c>) already
|
||||||
|
/// named slot <c>0x1000018C</c> as "gmMapUI+gmHouseUI pages", panel id 16 —
|
||||||
|
/// this session's <c>MapHousePanelSlotProbeTests</c> re-confirmed it live.
|
||||||
|
/// The toolbar button is <c>0x1000019A</c> (own authored
|
||||||
|
/// <c>P0x10000029 = 16</c>), one of three currently-ghosted panel buttons
|
||||||
|
/// (see <c>docs/research/2026-08-17-map-house-recon.md</c>).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Tab table — live-DAT-confirmed.</b>
|
||||||
|
/// <c>button 0x100001F3 -> page 0x100001F6 (Map, DEFAULT)</c>,
|
||||||
|
/// <c>button 0x100001F4 -> page 0x100001F7 (House)</c>. The page roots are
|
||||||
|
/// themselves typed <c>0x10000026</c> (gmMapUI) / <c>0x10000025</c>
|
||||||
|
/// (gmHouseUI) in the DAT — <see cref="DatWidgetFactory"/> has no special
|
||||||
|
/// case for either id, so they build as generic containers (same as every
|
||||||
|
/// other unmodeled retail UI class); <see cref="MapPageController"/> and
|
||||||
|
/// <see cref="HousePageController"/> find their own signature children by
|
||||||
|
/// id underneath.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Close button</b>: <c>0x100001F5</c> (Type 1, top-right at
|
||||||
|
/// <c>(276,0) 24x25</c>) — same authored position/size as the Social
|
||||||
|
/// panel's own close button, the established <c>gmPanelUI</c> sibling
|
||||||
|
/// convention.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapHousePanelController : IRetainedPanelController
|
||||||
|
{
|
||||||
|
public const uint HostLayoutId = 0x2100006Eu;
|
||||||
|
public const uint SlotElementId = 0x1000018Cu;
|
||||||
|
|
||||||
|
private const uint MapButtonId = 0x100001F3u;
|
||||||
|
private const uint MapPageId = 0x100001F6u;
|
||||||
|
private const uint HouseButtonId = 0x100001F4u;
|
||||||
|
private const uint HousePageId = 0x100001F7u;
|
||||||
|
private const uint CloseButtonId = 0x100001F5u;
|
||||||
|
|
||||||
|
public sealed record Callbacks(
|
||||||
|
Action Toggle,
|
||||||
|
MapPageController.Bindings Map,
|
||||||
|
HousePageController.Bindings House);
|
||||||
|
|
||||||
|
private readonly UiTabPanel _tabPanel;
|
||||||
|
private readonly MapPageController? _map;
|
||||||
|
private readonly HousePageController? _house;
|
||||||
|
private readonly Action<uint, uint> _onActivePageChanged;
|
||||||
|
private bool _visible;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public UiElement Root => _tabPanel;
|
||||||
|
public UiTabPanel TabPanel => _tabPanel;
|
||||||
|
|
||||||
|
private MapHousePanelController(
|
||||||
|
UiTabPanel tabPanel, MapPageController? map, HousePageController? house)
|
||||||
|
{
|
||||||
|
_tabPanel = tabPanel;
|
||||||
|
_map = map;
|
||||||
|
_house = house;
|
||||||
|
|
||||||
|
// Night-round review F2 (2026-08-17): this used to be House's
|
||||||
|
// outbound-HouseQuery fire-when-shown trigger; that trigger is
|
||||||
|
// REMOVED (former register row AD-107) now that HouseQuery is
|
||||||
|
// byte-decoded confirmed to fire once at retail's login-complete
|
||||||
|
// edge instead (see HousePageController.Bindings.OnShown's own
|
||||||
|
// updated doc). The "window shown AND my tab active" plumbing stays
|
||||||
|
// — it mirrors the social panel's Fellowship/Allegiance page-shown
|
||||||
|
// convention and remains available for a genuinely page-shown
|
||||||
|
// concern — but HousePageController.Bindings.OnShown is unwired
|
||||||
|
// (null) in production, so this call chain is currently a no-op.
|
||||||
|
_onActivePageChanged = (_, _) => FireHouseShownIfActive();
|
||||||
|
_tabPanel.ActivePageChanged += _onActivePageChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="rootInfo">The pre-Build <see cref="ElementInfo"/> tree
|
||||||
|
/// <paramref name="layout"/> was built from — <see cref="MapPageController"/>
|
||||||
|
/// needs it to read <c>m_pMap</c>'s own authored int/enum attrs, which
|
||||||
|
/// only exist on <see cref="ElementInfo"/>, not the built
|
||||||
|
/// <see cref="UiElement"/> tree.</param>
|
||||||
|
public static MapHousePanelController? Bind(
|
||||||
|
ElementInfo rootInfo, ImportedLayout layout, Callbacks callbacks)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rootInfo);
|
||||||
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
|
ArgumentNullException.ThrowIfNull(callbacks);
|
||||||
|
|
||||||
|
if (layout.Root is not UiTabPanel tabPanel)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
"[D.2b] MapHousePanelController.Bind: root did not build as UiTabPanel "
|
||||||
|
+ $"(actual type {layout.Root.GetType().Name}) — Map/House panel will not open.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout.FindElement(CloseButtonId) is UiButton close)
|
||||||
|
close.OnClick = callbacks.Toggle;
|
||||||
|
else
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[D.2b] MapHousePanelController: close button 0x{CloseButtonId:X8} not found.");
|
||||||
|
|
||||||
|
UiElement? mapPage = UiElement.FindDescendant(tabPanel, MapPageId);
|
||||||
|
UiElement? housePage = UiElement.FindDescendant(tabPanel, HousePageId);
|
||||||
|
|
||||||
|
MapPageController? map = null;
|
||||||
|
if (mapPage is not null)
|
||||||
|
{
|
||||||
|
ElementInfo? mapPageInfo = FindInfo(rootInfo, MapPageId);
|
||||||
|
map = mapPageInfo is null
|
||||||
|
? null
|
||||||
|
: MapPageController.Bind(mapPage, mapPageInfo, callbacks.Map);
|
||||||
|
}
|
||||||
|
HousePageController? house = housePage is null
|
||||||
|
? null
|
||||||
|
: HousePageController.Bind(housePage, callbacks.House);
|
||||||
|
|
||||||
|
if (mapPage is null)
|
||||||
|
Console.WriteLine($"[D.2b] MapHousePanelController: Map page 0x{MapPageId:X8} not found.");
|
||||||
|
if (housePage is null)
|
||||||
|
Console.WriteLine($"[D.2b] MapHousePanelController: House page 0x{HousePageId:X8} not found.");
|
||||||
|
|
||||||
|
return new MapHousePanelController(tabPanel, map, house);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
|
||||||
|
|
||||||
|
public bool IsShowingHouse => _tabPanel.ActivePageElementId == HousePageId;
|
||||||
|
|
||||||
|
public void OnShown()
|
||||||
|
{
|
||||||
|
_visible = true;
|
||||||
|
FireHouseShownIfActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnHidden() => _visible = false;
|
||||||
|
|
||||||
|
private void FireHouseShownIfActive()
|
||||||
|
{
|
||||||
|
if (_visible && IsShowingHouse)
|
||||||
|
_house?.OnShown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-frame poll: Map's 5 s-gated refresh (cheap when not due)
|
||||||
|
/// and House's revision-gated row rebuild. Both stay unconditional
|
||||||
|
/// (unlike Friends/Squelch's DAT-locked rebuild) — Map's own cadence
|
||||||
|
/// gate and House's list compare are both cheap even while hidden.</summary>
|
||||||
|
public void Tick(double deltaSeconds)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_map?.Tick(deltaSeconds);
|
||||||
|
_house?.Tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_tabPanel.ActivePageChanged -= _onActivePageChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElementInfo? FindInfo(ElementInfo info, uint id)
|
||||||
|
{
|
||||||
|
if (info.Id == id) return info;
|
||||||
|
foreach (ElementInfo child in info.Children)
|
||||||
|
{
|
||||||
|
ElementInfo? found = FindInfo(child, id);
|
||||||
|
if (found is not null) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/AcDream.App/UI/Layout/MapLocations.cs
Normal file
79
src/AcDream.App/UI/Layout/MapLocations.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One retail map hotspot rect + rollover name. Mirrors
|
||||||
|
/// <c>gmMapUI::LocationRolloverInfo</c> (<c>acclient.h:55686</c>): X/Y/Width/
|
||||||
|
/// Height are direct pixel offsets within <c>m_pMap</c> (no coordinate
|
||||||
|
/// transform — <c>gmMapUI::AddMapNote @0x004a1bb0</c> passes them straight to
|
||||||
|
/// <c>MoveTo</c>/<c>ResizeTo</c>), and Name is the literal tooltip text
|
||||||
|
/// (<c>StringInfo::SetLiteralValue</c>, not a DAT string-table lookup).
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct MapLocation(int X, int Y, int Width, int Height, string Name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verbatim port of retail's static <c>s_rgLocations[0x35]</c> table
|
||||||
|
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:977225-977651</c>),
|
||||||
|
/// consumed by <c>gmMapUI::PostInit @0x004a1c70</c> to place the 53 town
|
||||||
|
/// hotspots on the Map tab. Ported verbatim, in original index order —
|
||||||
|
/// do not resort or "clean up"; the order is not semantically meaningful
|
||||||
|
/// but the values must match byte-for-byte.
|
||||||
|
/// </summary>
|
||||||
|
public static class MapLocations
|
||||||
|
{
|
||||||
|
public static readonly MapLocation[] All =
|
||||||
|
{
|
||||||
|
new(0xb2, 0x14, 0xb, 0xc, "Aerlinthe Island"),
|
||||||
|
new(0x12, 0x4a, 0x5, 0x5, "Ahurenga"),
|
||||||
|
new(0x8d, 0xa6, 0x7, 0x6, "Al-Arqas"),
|
||||||
|
new(0x81, 0x79, 0x7, 0x6, "Al-Jalima"),
|
||||||
|
new(0xbe, 0x58, 0x9, 0x8, "Arwic"),
|
||||||
|
new(0x13, 0xc9, 0x7, 0x6, "Ayan Baqur"),
|
||||||
|
new(0xc8, 0xbe, 0x7, 0x6, "Baishi"),
|
||||||
|
new(0xb8, 0x35, 0x5, 0x5, "Bandit Castle"),
|
||||||
|
new(0x22, 0x54, 0x5, 0x5, "Bluespire"),
|
||||||
|
new(0x2c, 0xeb, 0x5, 0x5, "Candeth Keep"),
|
||||||
|
new(0xb4, 0x61, 0x9, 0x8, "Cragstone"),
|
||||||
|
new(0x5b, 0x66, 0x5, 0x5, "Danby's Outpost"),
|
||||||
|
new(0xd3, 0x8a, 0x9, 0x8, "Dryreach"),
|
||||||
|
new(0xc7, 0x6a, 0x9, 0x8, "Eastham"),
|
||||||
|
new(0x38, 0xd, 0x5, 0x5, "Fiun Outpost"),
|
||||||
|
new(0x25, 0x7f, 0x9, 0x8, "Fort Tethana"),
|
||||||
|
new(0x9c, 0x5e, 0x9, 0x8, "Glenden Wood"),
|
||||||
|
new(0x2b, 0x4f, 0x5, 0x5, "Greenspire"),
|
||||||
|
new(0xe0, 0xb1, 0x7, 0x6, "Hebian-to"),
|
||||||
|
new(0xa4, 0x4d, 0x9, 0x8, "Holtburg"),
|
||||||
|
new(0xb6, 0xe6, 0x7, 0x6, "Kara"),
|
||||||
|
new(0x9b, 0xb9, 0x7, 0x6, "Khayyaban"),
|
||||||
|
new(0xe0, 0xda, 0x7, 0x6, "Kryst"),
|
||||||
|
new(0xd4, 0xc3, 0x7, 0x6, "Lin"),
|
||||||
|
new(0x9f, 0xe0, 0x5, 0x5, "Linvak Tukal"),
|
||||||
|
new(0xb9, 0x7e, 0x9, 0x8, "Lytelthorpe"),
|
||||||
|
new(0xeb, 0xdc, 0x7, 0x6, "MacNiall's Freehold"),
|
||||||
|
new(0xdf, 0xcb, 0x7, 0x6, "Mayoi"),
|
||||||
|
new(0x8d, 0x35, 0x5, 0x5, "Mt Esper-Crater Village"),
|
||||||
|
new(0xe0, 0xbf, 0x7, 0x6, "Nanto"),
|
||||||
|
new(0x8e, 0x2e, 0x5, 0x5, "Neydisa"),
|
||||||
|
new(0xf0, 0x80, 0x5, 0x5, "Oolutanga's Refuge"),
|
||||||
|
new(0x4a, 0x4f, 0x5, 0x5, "Plateau Village"),
|
||||||
|
new(0x94, 0xda, 0x7, 0x6, "Qalaba'r"),
|
||||||
|
new(0x1a, 0x53, 0x5, 0x5, "Redspire"),
|
||||||
|
new(0xc1, 0x72, 0x9, 0x8, "Rithwic"),
|
||||||
|
new(0x92, 0x85, 0x7, 0x6, "Samsur"),
|
||||||
|
new(0x32, 0x2a, 0x5, 0x5, "Sanamar"),
|
||||||
|
new(0xc3, 0xa3, 0x7, 0x6, "Sawato"),
|
||||||
|
new(0xd5, 0xab, 0x7, 0x6, "Shoushi"),
|
||||||
|
new(0x29, 0x19, 0x5, 0x5, "Silyun"),
|
||||||
|
new(0x6, 0xef, 0xf, 0x10, "Singularity Caul Island"),
|
||||||
|
new(0x64, 0x30, 0x5, 0x5, "Stonehold"),
|
||||||
|
new(0x20, 0x4c, 0x5, 0x5, "Timaru"),
|
||||||
|
new(0xef, 0xa3, 0x7, 0x6, "Tou-Tou"),
|
||||||
|
new(0x83, 0x94, 0x7, 0x6, "Tufa"),
|
||||||
|
new(0x70, 0xf4, 0x5, 0x5, "Ulgrim's Island"),
|
||||||
|
new(0x9f, 0xa0, 0x7, 0x6, "Uziz"),
|
||||||
|
new(0x3f, 0xcb, 0x7, 0x6, "Wai Jhou"),
|
||||||
|
new(0x90, 0xb5, 0x7, 0x6, "Xarabydun"),
|
||||||
|
new(0xaf, 0x91, 0x7, 0x6, "Yanshi"),
|
||||||
|
new(0x79, 0x9c, 0x7, 0x6, "Yaraq"),
|
||||||
|
new(0x7b, 0x70, 0x7, 0x6, "Zaikhal"),
|
||||||
|
};
|
||||||
|
}
|
||||||
525
src/AcDream.App/UI/Layout/MapPageController.cs
Normal file
525
src/AcDream.App/UI/Layout/MapPageController.cs
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Ui;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds the Map tab of the retail Map/House panel (<c>gmMapUI</c>, class id
|
||||||
|
/// <c>0x10000026</c>) — <see cref="MapHousePanelController"/>'s default page.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail references: <c>gmMapUI::PostInit @0x004a1c70</c> (child
|
||||||
|
/// resolution + hotspot template setup), <c>gmMapUI::Update @0x004a1eb0</c>
|
||||||
|
/// (5 s refresh cadence — date/time text, coordinate readout, both
|
||||||
|
/// markers), <c>gmMapUI::PlaceMarkerOnMap @0x004a18b0</c> (marker centering
|
||||||
|
/// math), <c>gmMapUI::AddMapNote @0x004a1bb0</c> (town hotspot
|
||||||
|
/// instantiation + literal-string tooltip). Live-DAT byte values confirmed
|
||||||
|
/// by <c>MapHousePanelSlotProbeTests</c>: marker area
|
||||||
|
/// <c>(6,8)-(247,258)</c>, hotspot template element <c>0x100001F0</c> in
|
||||||
|
/// LayoutDesc <c>0x21000026</c>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Coordinate math reuses <see cref="RadarCoordinates"/> (already a byte-
|
||||||
|
/// exact port of the same <c>CPlayerSystem::InqPlayerCoords @0x00560090</c>
|
||||||
|
/// formula the radar's own coordinate strip uses) rather than re-deriving
|
||||||
|
/// it — see the recon doc's "no re-port needed" note.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapPageController
|
||||||
|
{
|
||||||
|
// gmMapUI PostInit signature children (pc:171993).
|
||||||
|
public const uint DateTimeTextId = 0x100001EBu;
|
||||||
|
public const uint MapWidgetId = 0x100001ECu;
|
||||||
|
public const uint PlayerIconId = 0x100001EDu;
|
||||||
|
public const uint HouseIconId = 0x100001EEu;
|
||||||
|
public const uint CoordinateTextId = 0x100001EFu;
|
||||||
|
|
||||||
|
// m_pMap's own authored attrs (gmMapUI::PostInit @0x004a1c70).
|
||||||
|
private const uint MarkerAreaX0Attr = 0x1000004Eu;
|
||||||
|
private const uint MarkerAreaX1Attr = 0x1000004Fu;
|
||||||
|
private const uint MarkerAreaY0Attr = 0x10000050u;
|
||||||
|
private const uint MarkerAreaY1Attr = 0x10000051u;
|
||||||
|
private const uint HotspotTemplateElementAttr = 0x47u;
|
||||||
|
private const uint HotspotTemplateLayoutAttr = 0x48u;
|
||||||
|
|
||||||
|
/// <summary>Retail's own 5 s tick cadence (<c>gmMapUI::Update</c>'s
|
||||||
|
/// <c>m_nextUpdate = Timer::cur_time + 5.0</c>).</summary>
|
||||||
|
public const double RefreshIntervalSeconds = 5.0;
|
||||||
|
|
||||||
|
public sealed record Bindings(
|
||||||
|
Func<DerethDateTime.Calendar> CurrentCalendar,
|
||||||
|
Func<uint> PlayerCellId,
|
||||||
|
// Slice 4 wires the real RuntimeHouseState-backed callback; defaults
|
||||||
|
// to "no house" (matching retail's Position::IsValid == false
|
||||||
|
// branch — the house icon starts/stays hidden) so this page works
|
||||||
|
// standalone before that lands.
|
||||||
|
Func<CreateObject.ServerPosition?> HousePosition,
|
||||||
|
Func<uint, uint, UiElement?> TemplateResolver,
|
||||||
|
// Builds one UiElement subtree from an ALREADY-RESOLVED ElementInfo
|
||||||
|
// (production: LayoutImporter.Build under the DAT lock — the same
|
||||||
|
// build half RowTemplateResolver uses, without the import half).
|
||||||
|
// Used for m_pMap's two button-swallowed icon children, whose
|
||||||
|
// ElementInfos only exist inside the full panel-slot resolve tree —
|
||||||
|
// a cold ImportInfos(hostLayout, iconId) re-import CANNOT find them
|
||||||
|
// (register row AD-108's live-DAT finding; see Bind's own doc).
|
||||||
|
Func<ElementInfo, UiElement?> IconBuilder);
|
||||||
|
|
||||||
|
private readonly UiElement? _dateTimeText;
|
||||||
|
private readonly UiElement? _map;
|
||||||
|
private readonly UiElement? _playerIcon;
|
||||||
|
private readonly UiElement? _houseIcon;
|
||||||
|
private readonly UiElement? _coordinateText;
|
||||||
|
private readonly Bindings _bindings;
|
||||||
|
private readonly int _markerX0, _markerX1, _markerY0, _markerY1;
|
||||||
|
|
||||||
|
private double _nextUpdateSeconds;
|
||||||
|
private string? _lastDateTimeText;
|
||||||
|
private string? _lastCoordinateText;
|
||||||
|
|
||||||
|
private MapPageController(
|
||||||
|
UiElement? dateTimeText,
|
||||||
|
UiElement map,
|
||||||
|
UiElement? playerIcon,
|
||||||
|
UiElement? houseIcon,
|
||||||
|
UiElement? coordinateText,
|
||||||
|
(int X0, int X1, int Y0, int Y1) markerArea,
|
||||||
|
Bindings bindings)
|
||||||
|
{
|
||||||
|
_dateTimeText = dateTimeText;
|
||||||
|
_map = map;
|
||||||
|
_playerIcon = playerIcon;
|
||||||
|
_houseIcon = houseIcon;
|
||||||
|
_coordinateText = coordinateText;
|
||||||
|
_bindings = bindings;
|
||||||
|
(_markerX0, _markerX1, _markerY0, _markerY1) = markerArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds an already-built page root (<see cref="MapHousePanelController.Bind"/>
|
||||||
|
/// resolves the page via the panel's tab table). Reads <c>m_pMap</c>'s
|
||||||
|
/// own marker-area rect straight from the ORIGINAL <see cref="ElementInfo"/>
|
||||||
|
/// (post-Build widgets don't carry authored int attrs), instantiates the
|
||||||
|
/// 53 town hotspots once, and returns a controller ready for
|
||||||
|
/// per-frame <see cref="Tick"/> polling.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Live-DAT structural finding (MapHousePanelSlotProbeTests' follow-up
|
||||||
|
/// dump):</b> <c>m_pMap</c> (<c>0x100001EC</c>) is itself authored as a
|
||||||
|
/// Type-1 BUTTON (the GM click-to-teleport feature at
|
||||||
|
/// <c>gmMapUI::ListenToElementMessage @0x004a2350</c> idMessage
|
||||||
|
/// <c>0x1c</c>), and the player/house icons (<c>0x100001ED</c>/
|
||||||
|
/// <c>0x100001EE</c>) are authored as ITS OWN nested children, not
|
||||||
|
/// siblings. <see cref="UiButton.ConsumesDatChildren"/> swallows a
|
||||||
|
/// button's dat children as skin/label parts, so they never appear in
|
||||||
|
/// the normally-built tree — <see cref="UiElement.FindDescendant"/>
|
||||||
|
/// against the page root always returns null for them. Their
|
||||||
|
/// <see cref="ElementInfo"/>s, however, DO survive: <paramref name="pageInfo"/>
|
||||||
|
/// is a subtree of the panel's full slot resolve
|
||||||
|
/// (<c>ImportInfos(0x2100006E, 0x1000018C)</c>), the only pathway that
|
||||||
|
/// materializes them at all — a cold
|
||||||
|
/// <c>ImportInfos(hostLayoutId, iconElementId)</c> starting from the
|
||||||
|
/// icon id returns null on the live DAT because the raw LayoutDesc
|
||||||
|
/// <c>Elements</c>-table walk never reaches them (register row AD-108's
|
||||||
|
/// live-DAT finding, 2026-08-17). So the icons are resolved by finding
|
||||||
|
/// their infos under <c>m_pMap</c>'s own already-resolved info and
|
||||||
|
/// BUILDING each via <see cref="Bindings.IconBuilder"/>, then attached
|
||||||
|
/// under <c>m_pMap</c> directly — their authored local position is
|
||||||
|
/// irrelevant since <see cref="PlaceMarker"/> overwrites it every
|
||||||
|
/// refresh.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static MapPageController? Bind(UiElement page, ElementInfo pageInfo, Bindings bindings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(page);
|
||||||
|
ArgumentNullException.ThrowIfNull(pageInfo);
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
|
||||||
|
UiElement? map = UiElement.FindDescendant(page, MapWidgetId);
|
||||||
|
if (map is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[D.2b] Map tab: m_pMap 0x{MapWidgetId:X8} not found — Map tab will not populate.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ElementInfo? mapInfo = FindInfo(pageInfo, MapWidgetId);
|
||||||
|
var markerArea = (X0: 0, X1: 0, Y0: 0, Y1: 0);
|
||||||
|
if (mapInfo is not null)
|
||||||
|
{
|
||||||
|
int x0 = mapInfo.TryGetEffectiveProperty(MarkerAreaX0Attr, out var vx0) ? vx0.IntegerValue : 0;
|
||||||
|
int x1 = mapInfo.TryGetEffectiveProperty(MarkerAreaX1Attr, out var vx1) ? vx1.IntegerValue : 0;
|
||||||
|
int y0 = mapInfo.TryGetEffectiveProperty(MarkerAreaY0Attr, out var vy0) ? vy0.IntegerValue : 0;
|
||||||
|
int y1 = mapInfo.TryGetEffectiveProperty(MarkerAreaY1Attr, out var vy1) ? vy1.IntegerValue : 0;
|
||||||
|
markerArea = (x0, x1, y0, y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
UiElement? playerIcon = ResolveSwallowedIcon(map, mapInfo, bindings.IconBuilder, PlayerIconId);
|
||||||
|
UiElement? houseIcon = ResolveSwallowedIcon(map, mapInfo, bindings.IconBuilder, HouseIconId);
|
||||||
|
|
||||||
|
var controller = new MapPageController(
|
||||||
|
UiElement.FindDescendant(page, DateTimeTextId),
|
||||||
|
map,
|
||||||
|
playerIcon,
|
||||||
|
houseIcon,
|
||||||
|
UiElement.FindDescendant(page, CoordinateTextId),
|
||||||
|
markerArea,
|
||||||
|
bindings);
|
||||||
|
|
||||||
|
controller.BuildTownMarkers(mapInfo, bindings.TemplateResolver);
|
||||||
|
|
||||||
|
// UiText is a pull-based scrollback widget (LinesProvider), not an
|
||||||
|
// imperative SetText target — wire the provider ONCE here to read
|
||||||
|
// the mutable backing field Refresh() updates, matching the
|
||||||
|
// established pattern (e.g. CharacterStatController's xpValue).
|
||||||
|
if (controller._dateTimeText is UiText dateTimeText)
|
||||||
|
dateTimeText.LinesProvider = () => ToLines(controller._lastDateTimeText, dateTimeText.DefaultColor);
|
||||||
|
if (controller._coordinateText is UiText coordinateText)
|
||||||
|
coordinateText.LinesProvider = () => ToLines(controller._lastCoordinateText, coordinateText.DefaultColor);
|
||||||
|
|
||||||
|
// Immediate first refresh rather than waiting out the first 5 s tick.
|
||||||
|
controller.Refresh();
|
||||||
|
controller._nextUpdateSeconds = RefreshIntervalSeconds;
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves one of <c>m_pMap</c>'s button-swallowed nested
|
||||||
|
/// icon children by finding its <see cref="ElementInfo"/> under
|
||||||
|
/// <paramref name="mapInfo"/> — the panel-slot resolve tree, the ONLY
|
||||||
|
/// place these infos exist (see <see cref="Bind"/>'s own doc + register
|
||||||
|
/// row AD-108) — building it via <paramref name="iconBuilder"/>, and
|
||||||
|
/// attaching it under <paramref name="map"/>. Starts hidden — the first
|
||||||
|
/// <see cref="Refresh"/> call (from <see cref="Bind"/>) decides real
|
||||||
|
/// visibility.</summary>
|
||||||
|
private static UiElement? ResolveSwallowedIcon(
|
||||||
|
UiElement map, ElementInfo? mapInfo, Func<ElementInfo, UiElement?> iconBuilder, uint iconElementId)
|
||||||
|
{
|
||||||
|
// If the normal build walk ever stops swallowing m_pMap's dat
|
||||||
|
// children (a future UiButton.ConsumesDatChildren policy change),
|
||||||
|
// the icon already exists in the built tree — use it rather than
|
||||||
|
// building a second, permanently-static copy behind the live
|
||||||
|
// marker. Retail's own PostInit is exactly this find-the-child.
|
||||||
|
UiElement? existing = UiElement.FindDescendant(map, iconElementId);
|
||||||
|
if (existing is not null)
|
||||||
|
return PrepareIcon(existing);
|
||||||
|
|
||||||
|
ElementInfo? iconInfo = mapInfo is null ? null : FindInfo(mapInfo, iconElementId);
|
||||||
|
if (iconInfo is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[D.2b] Map tab: icon 0x{iconElementId:X8} not authored under m_pMap's resolved "
|
||||||
|
+ "info tree — it will not be shown.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
UiElement? icon = iconBuilder(iconInfo);
|
||||||
|
if (icon is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[D.2b] Map tab: icon 0x{iconElementId:X8} did not build — it will not be shown.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
map.AddChild(PrepareIcon(icon));
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Marks one marker icon as runtime-positioned. <see cref="PlaceMarker"/>
|
||||||
|
/// owns the element's position outright (retail's <c>gmMapUI::Update</c>
|
||||||
|
/// re-places both markers every tick) — but acdream re-runs the authored
|
||||||
|
/// layout pass per frame, so the compatibility anchor capture (and any
|
||||||
|
/// imported raw-edge <see cref="UiElement.LayoutPolicy"/>, which the
|
||||||
|
/// <see cref="UiElement.Anchors"/> setter clears) would re-assert the
|
||||||
|
/// authored (0,0) rect every frame, silently overwriting PlaceMarker's
|
||||||
|
/// writes — the F1 live finding: a visible green ring pinned to m_pMap's
|
||||||
|
/// top-left corner regardless of the player's true position. Starts
|
||||||
|
/// hidden — the first <see cref="Refresh"/> decides real visibility.</summary>
|
||||||
|
private static UiElement PrepareIcon(UiElement icon)
|
||||||
|
{
|
||||||
|
icon.Anchors = AnchorEdges.None;
|
||||||
|
icon.Visible = false;
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<UiText.Line> ToLines(string? text, System.Numerics.Vector4 color)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return Array.Empty<UiText.Line>();
|
||||||
|
string[] parts = text.Split('\n');
|
||||||
|
var lines = new UiText.Line[parts.Length];
|
||||||
|
for (int i = 0; i < parts.Length; i++)
|
||||||
|
lines[i] = new UiText.Line(parts[i], color);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates the 53 static town hotspots (<c>gmMapUI::AddMapNote</c>)
|
||||||
|
/// from <c>m_pMap</c>'s own <c>0x47</c>/<c>0x48</c> template attrs. A
|
||||||
|
/// missing template (either attr absent, or the DAT install lacks the
|
||||||
|
/// referenced LayoutDesc/element) leaves the map usable without
|
||||||
|
/// hotspots rather than failing the whole page — matches retail's own
|
||||||
|
/// null-guarded <c>if (eax_10 != 0)</c> before the loop.
|
||||||
|
/// </summary>
|
||||||
|
private void BuildTownMarkers(ElementInfo? mapInfo, Func<uint, uint, UiElement?> templateResolver)
|
||||||
|
{
|
||||||
|
if (mapInfo is null) return;
|
||||||
|
if (!mapInfo.TryGetEffectiveProperty(HotspotTemplateElementAttr, out var templateElement)) return;
|
||||||
|
if (!mapInfo.TryGetEffectiveProperty(HotspotTemplateLayoutAttr, out var templateLayout)) return;
|
||||||
|
if (templateLayout.UnsignedValue == 0) return;
|
||||||
|
|
||||||
|
foreach (MapLocation loc in MapLocations.All)
|
||||||
|
{
|
||||||
|
UiElement? marker = templateResolver(
|
||||||
|
(uint)templateLayout.UnsignedValue, (uint)templateElement.UnsignedValue);
|
||||||
|
if (marker is null) continue;
|
||||||
|
|
||||||
|
marker.Left = loc.X;
|
||||||
|
marker.Top = loc.Y;
|
||||||
|
marker.Width = loc.Width;
|
||||||
|
marker.Height = loc.Height;
|
||||||
|
// gmMapUI::AddMapNote's UIElement::SetTooltip call — a LITERAL
|
||||||
|
// string (StringInfo::SetLiteralValue), not a DAT table lookup —
|
||||||
|
// i.e. retail's RUNTIME m_TTText mechanism, not the authored
|
||||||
|
// P0x49 path. UiButton.TooltipText is the exact settable seam
|
||||||
|
// backing UiElement.GetTooltipText()'s override, which
|
||||||
|
// RetailTooltipPresenter.ResolveTooltipText consults BEFORE the
|
||||||
|
// authored text (closes register row TS-85's last item,
|
||||||
|
// gmMapUI::AddMapNote @0x004A1C51). AuthoredTooltipRootElementId/
|
||||||
|
// LayoutDid still gate the popup SKIN unconditionally even on
|
||||||
|
// the runtime-text path — the map-note template (0x100001F0)
|
||||||
|
// authors no individual tooltip-popup locator of its own (a
|
||||||
|
// plain 10x10 hotspot dot), so RetailTooltipPresenter's popup
|
||||||
|
// needs one supplied; RetailTooltipPresenter.SharedPopupSkinRootElementId/
|
||||||
|
// SharedPopupSkinLayoutDid (see that class's own single
|
||||||
|
// canonical citation, night-round review F10) is the same
|
||||||
|
// proven-working skin UiItemSlot already hardcodes.
|
||||||
|
if (marker is UiButton markerButton)
|
||||||
|
markerButton.TooltipText = loc.Name;
|
||||||
|
else
|
||||||
|
// F11 (night-round review): silently skipping the runtime-
|
||||||
|
// text write here would leave the marker's popup mounted
|
||||||
|
// (AuthoredTooltipRootElementId/LayoutDid are still set
|
||||||
|
// below) but genuinely EMPTY — a live-DAT template change
|
||||||
|
// that resolves 0x100001F0 to something other than a
|
||||||
|
// UiButton would regress every town-marker tooltip with no
|
||||||
|
// diagnostic signal at all.
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[D.2b] Map tab: town marker '{loc.Name}' template "
|
||||||
|
+ $"resolved to {marker.GetType().Name}, not UiButton — "
|
||||||
|
+ "TooltipText cannot be set, marker will show no tooltip.");
|
||||||
|
marker.AuthoredTooltipRootElementId = RetailTooltipPresenter.SharedPopupSkinRootElementId;
|
||||||
|
marker.AuthoredTooltipLayoutDid = RetailTooltipPresenter.SharedPopupSkinLayoutDid;
|
||||||
|
_map!.AddChild(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-frame poll, accumulating wall-clock deltas
|
||||||
|
/// (<see cref="RetailUiRuntime.Tick"/>'s own shape) into retail's 5 s
|
||||||
|
/// cadence — same net effect as <c>Timer::cur_time</c> comparison
|
||||||
|
/// without needing a separate absolute clock dependency.</summary>
|
||||||
|
public void Tick(double deltaSeconds)
|
||||||
|
{
|
||||||
|
_nextUpdateSeconds -= deltaSeconds;
|
||||||
|
if (_nextUpdateSeconds > 0) return;
|
||||||
|
_nextUpdateSeconds = RefreshIntervalSeconds;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Refresh()
|
||||||
|
{
|
||||||
|
RefreshDateTime();
|
||||||
|
RefreshCoordinatesAndPlayerMarker();
|
||||||
|
RefreshHouseMarker();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshDateTime()
|
||||||
|
{
|
||||||
|
if (_dateTimeText is null) return;
|
||||||
|
DerethDateTime.Calendar calendar = _bindings.CurrentCalendar();
|
||||||
|
string text = FormatDateTime(calendar);
|
||||||
|
// gmMapUI::Update only calls SetText when the string actually
|
||||||
|
// differs (wcscmp change-detect), not a re-stamp every 5s. The
|
||||||
|
// LinesProvider wired in Bind() re-reads this field lazily, so
|
||||||
|
// updating it IS the display update.
|
||||||
|
_lastDateTimeText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>"Date: %s\nTime: %s"</c> (<c>gmMapUI::Update</c>'s sprintf shape,
|
||||||
|
/// fed by <c>GameTime::GetDateTimeString @0x005a6530</c>). Month names
|
||||||
|
/// already match retail display text 1:1
|
||||||
|
/// (<see cref="DerethDateTime.MonthName"/>); hour names need the
|
||||||
|
/// "AndHalf" suffix rewritten to "-and-Half".
|
||||||
|
/// </summary>
|
||||||
|
internal static string FormatDateTime(DerethDateTime.Calendar calendar) =>
|
||||||
|
$"Date: {calendar.Month} {calendar.Day}, {calendar.Year} P.Y.\nTime: {FormatHourName(calendar.Hour)}";
|
||||||
|
|
||||||
|
private static string FormatHourName(DerethDateTime.HourName hour)
|
||||||
|
{
|
||||||
|
string name = hour.ToString();
|
||||||
|
const string suffix = "AndHalf";
|
||||||
|
return name.EndsWith(suffix, StringComparison.Ordinal)
|
||||||
|
? string.Concat(name.AsSpan(0, name.Length - suffix.Length), "-and-Half")
|
||||||
|
: name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>gmMapUI::Update @0x004a2078</c>'s gate is
|
||||||
|
/// <c>if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)</c>
|
||||||
|
/// — BOTH widgets present, not "at least one". Night-round review F15:
|
||||||
|
/// the prior <c>_coordinateText is null && _playerIcon is null</c>
|
||||||
|
/// check only skipped this method when BOTH were absent (De Morgan's:
|
||||||
|
/// it PROCEEDED whenever EITHER was present), so a page missing one of
|
||||||
|
/// the two would still write the other's state independently — retail
|
||||||
|
/// updates NEITHER when either is missing (no coordinate-text write,
|
||||||
|
/// no marker show/hide) since the whole outside/inside branch,
|
||||||
|
/// including its inside-branch fallback, lives inside this one gate.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshCoordinatesAndPlayerMarker()
|
||||||
|
{
|
||||||
|
if (_coordinateText is null || _playerIcon is null) return;
|
||||||
|
|
||||||
|
bool outside = RadarCoordinates.TryFromCell(_bindings.PlayerCellId(), out RadarCoordinates coords);
|
||||||
|
if (outside)
|
||||||
|
{
|
||||||
|
_lastCoordinateText = coords.CombinedText;
|
||||||
|
PlaceMarker(_playerIcon, coords.X, coords.Y);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Indoors: retail clears the coordinate text and hides the
|
||||||
|
// player marker (gmMapUI::Update's else branch,
|
||||||
|
// m_pPlayerLocationIcon->SetVisible(0)).
|
||||||
|
_lastCoordinateText = string.Empty;
|
||||||
|
_playerIcon.Visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>gmMapUI::Update @0x004a22a6-f6</c>: <c>Position::get_outside_cell_id
|
||||||
|
/// (&m_HousePosition) -> LandDefs::gid_to_lcoord</c> -> the SAME
|
||||||
|
/// <c>(v-0x400)*0.1+0.5</c> transform <see cref="PlaceMarker"/>'s player
|
||||||
|
/// branch uses.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Night-round review F14: this passes <c>housePosition.Value.LandblockId</c>
|
||||||
|
/// straight to <see cref="RadarCoordinates.TryFromCell"/>, SKIPPING the
|
||||||
|
/// <c>Position::get_outside_cell_id @0x004527b0</c> step retail's own
|
||||||
|
/// call chain names. That function is itself BN-mangled (its final
|
||||||
|
/// <c>return ((eax_2 - eax_2) & objcell_id)</c> — an always-zero
|
||||||
|
/// subtraction ANDed with the cell id — is textbook Binary Ninja
|
||||||
|
/// obscuring a real conditional the raw bytes would need to
|
||||||
|
/// disassemble to recover, the same artifact class F1/F3 hit
|
||||||
|
/// elsewhere this round) and depends on <c>LandDefs::adjust_to_outside</c>,
|
||||||
|
/// which takes the position's raw world XYZ (not just the landblock
|
||||||
|
/// id) — a genuinely different, larger port than this round's other
|
||||||
|
/// findings, not a one-line fix. Documenting the gap rather than
|
||||||
|
/// guessing at the byte-decode (per this finding's own explicit
|
||||||
|
/// escape hatch): <see cref="Bindings.HousePosition"/> is wired
|
||||||
|
/// <c>() => null</c> in production today (ISSUES #413's remaining
|
||||||
|
/// owned-house scope), so this whole method is UNREACHABLE live —
|
||||||
|
/// there is no current behavioral gap to observe, only a latent one
|
||||||
|
/// for whenever HousePosition gets wired to real HouseData. TODO:
|
||||||
|
/// when that lands, port <c>Position::get_outside_cell_id</c> /
|
||||||
|
/// <c>LandDefs::adjust_to_outside</c> (byte-decode required,
|
||||||
|
/// <c>@0x004527b0</c> / call site <c>@0x004a2297</c>) instead of
|
||||||
|
/// passing the raw landblock id through — for a genuinely outdoor
|
||||||
|
/// house position this simplification is very likely already exact
|
||||||
|
/// (an outdoor position has nothing for <c>adjust_to_outside</c> to
|
||||||
|
/// adjust), but that has not been byte-confirmed, and an indoor
|
||||||
|
/// house-interior recall position would need the real conversion
|
||||||
|
/// rather than this method's current fail-safe (hide the marker,
|
||||||
|
/// since <see cref="RadarCoordinates.TryFromCell"/> correctly refuses
|
||||||
|
/// any cell with an envcell low word).
|
||||||
|
/// </remarks>
|
||||||
|
private void RefreshHouseMarker()
|
||||||
|
{
|
||||||
|
if (_houseIcon is null) return;
|
||||||
|
|
||||||
|
CreateObject.ServerPosition? housePosition = _bindings.HousePosition();
|
||||||
|
if (housePosition is null)
|
||||||
|
{
|
||||||
|
_houseIcon.Visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RadarCoordinates.TryFromCell(housePosition.Value.LandblockId, out RadarCoordinates coords))
|
||||||
|
{
|
||||||
|
_houseIcon.Visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaceMarker(_houseIcon, coords.X, coords.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>gmMapUI::PlaceMarkerOnMap @0x004a18b0</c>, ported from a direct
|
||||||
|
/// byte-read of the PDB-paired <c>acclient.exe</c> (Binary Ninja elides
|
||||||
|
/// the whole FPU chain to bare, operand-less <c>_ftol2()</c> calls —
|
||||||
|
/// see <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>
|
||||||
|
/// lines 171827-171855 — so the pseudo-C alone under-specifies this
|
||||||
|
/// function; capstone disassembly of the raw machine code at that VA
|
||||||
|
/// is the ground truth here, not the BN text). The prior "center at
|
||||||
|
/// markerX0+x" reading was WRONG — retail projects the AC display
|
||||||
|
/// coordinate (<paramref name="x"/>/<paramref name="y"/>, range
|
||||||
|
/// approximately ±102.4) onto the marker-area rect's pixel span via a
|
||||||
|
/// fixed-point-style transform, not a raw pixel add:
|
||||||
|
/// <code>
|
||||||
|
/// X = m_x0 - w/2 - (int)( (m_x1-m_x0+1) * (x*10+1024) * (-1/2048) )
|
||||||
|
/// Y = m_y0 - h/2 - (int)( (m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048) )
|
||||||
|
/// </code>
|
||||||
|
/// Constants read straight from the binary's .rdata: <c>0x79bac8</c> =
|
||||||
|
/// 10.0, <c>0x7aac78</c> = 1024.0, <c>0x7aac70</c> = -1/2048 (exactly
|
||||||
|
/// -0.00048828125), <c>0x7aac68</c> = 2047.0. The Y axis's FSUBR
|
||||||
|
/// (reversed subtract) is retail's north-up flip — Y increases upward
|
||||||
|
/// on the AC coordinate system but downward in screen pixels.
|
||||||
|
/// <c>w</c>/<c>h</c> are the icon's own <c>UIRegion::GetWidth</c>/
|
||||||
|
/// <c>GetHeight</c> (@0x0069efe0/@0x0069eff0), halved with INTEGER
|
||||||
|
/// (truncating) division to match retail's <c>cdq;sub;sar</c> idiom —
|
||||||
|
/// not float division, which would drift by half a pixel on
|
||||||
|
/// odd-sized icons. Golden case (marker area (6,8)-(247,258), 10x10
|
||||||
|
/// icon, position 0.0N/0.0E) reproduces exactly to (122,128) center.
|
||||||
|
/// </summary>
|
||||||
|
private void PlaceMarker(UiElement? icon, double x, double y)
|
||||||
|
{
|
||||||
|
if (icon is null) return;
|
||||||
|
|
||||||
|
(float left, float top) = ComputeMarkerPosition(
|
||||||
|
_markerX0, _markerX1, _markerY0, _markerY1,
|
||||||
|
(int)icon.Width, (int)icon.Height, x, y);
|
||||||
|
icon.Left = left;
|
||||||
|
icon.Top = top;
|
||||||
|
icon.Visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pure <c>PlaceMarkerOnMap</c> math, split out from <see cref="PlaceMarker"/>
|
||||||
|
/// so tests can assert byte-decoded GOLDEN PIXEL values directly against
|
||||||
|
/// the formula instead of round-tripping through the port's own output.
|
||||||
|
/// </summary>
|
||||||
|
internal static (float Left, float Top) ComputeMarkerPosition(
|
||||||
|
int markerX0, int markerX1, int markerY0, int markerY1,
|
||||||
|
int iconWidth, int iconHeight, double x, double y)
|
||||||
|
{
|
||||||
|
int halfWidth = iconWidth / 2;
|
||||||
|
int halfHeight = iconHeight / 2;
|
||||||
|
int extentX = markerX1 - markerX0 + 1;
|
||||||
|
int extentY = markerY1 - markerY0 + 1;
|
||||||
|
|
||||||
|
int xOffset = (int)(extentX * (x * 10.0 + 1024.0) * (-1.0 / 2048.0));
|
||||||
|
int yOffset = (int)(extentY * (2047.0 - (y * 10.0 + 1024.0)) * (-1.0 / 2048.0));
|
||||||
|
|
||||||
|
return (markerX0 - halfWidth - xOffset, markerY0 - halfHeight - yOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElementInfo? FindInfo(ElementInfo info, uint id)
|
||||||
|
{
|
||||||
|
if (info.Id == id) return info;
|
||||||
|
foreach (ElementInfo child in info.Children)
|
||||||
|
{
|
||||||
|
ElementInfo? found = FindInfo(child, id);
|
||||||
|
if (found is not null) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -186,9 +186,34 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
/// family resolves to). Extracted unchanged from the pre-#411-follow-on
|
/// family resolves to). Extracted unchanged from the pre-#411-follow-on
|
||||||
/// <c>OnTooltipShow</c> body — same F4/F5/F8 fixes, same failure
|
/// <c>OnTooltipShow</c> body — same F4/F5/F8 fixes, same failure
|
||||||
/// handling.
|
/// handling.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Night-round review F5: the single-popup invariant (retail's own
|
||||||
|
/// single <c>m_pTooltipElement</c> slot) is now enforced HERE,
|
||||||
|
/// structurally, rather than relying on every caller to have already
|
||||||
|
/// cleared a stale popup before reaching this method. Both existing
|
||||||
|
/// callers already clear on their own early-return paths too (a hover
|
||||||
|
/// change that resolves to no valid tooltip text must still tear down
|
||||||
|
/// the PREVIOUS popup, which never reaches this method at all), so
|
||||||
|
/// those calls stay — this is a belt-and-braces guarantee, not a
|
||||||
|
/// replacement for them. It closes a real hole: <see cref="UpdateWorldHoverTooltip"/>'s
|
||||||
|
/// own clear is gated on <c>_worldTooltipShowing</c> (only true when the
|
||||||
|
/// WORLD path itself mounted the current popup) and its "a UI popup
|
||||||
|
/// cannot be showing here" comment assumed <see cref="_host"/>'s hover
|
||||||
|
/// query is null whenever that branch runs — an assumption that does
|
||||||
|
/// not hold the instant a modal dialog opens over a stationary cursor:
|
||||||
|
/// the UI dwell popup from <see cref="OnTooltipShow"/> stays mounted
|
||||||
|
/// (<c>_owner</c>/<c>_popupRoot</c> set, <c>_worldTooltipShowing</c>
|
||||||
|
/// still false) while the world path could independently find an
|
||||||
|
/// object and call this method, mounting a second popup on top. Now it
|
||||||
|
/// cannot: this call clears whatever is mounted, UI-owned or
|
||||||
|
/// world-owned, before either ever gets a chance to layer.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText)
|
private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText)
|
||||||
{
|
{
|
||||||
|
RemovePopup();
|
||||||
|
|
||||||
ImportedLayout? layout;
|
ImportedLayout? layout;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -295,17 +320,39 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
// directly by gmGamePlayUI's own mode setup rather than from a
|
// directly by gmGamePlayUI's own mode setup rather than from a
|
||||||
// walkable authored ElementDesc, so its own P0x47/P0x48 cannot be
|
// walkable authored ElementDesc, so its own P0x47/P0x48 cannot be
|
||||||
// read from the DAT. This port therefore REUSES the item catalog's
|
// read from the DAT. This port therefore REUSES the item catalog's
|
||||||
// confirmed uniform popup-locator pair (WorldPopupRootElementId/
|
// confirmed uniform popup-locator pair (SharedPopupSkinRootElementId/
|
||||||
// WorldPopupLayoutDid below) — the SAME "generic runtime-text" skin
|
// SharedPopupSkinLayoutDid below) — the SAME "generic runtime-text"
|
||||||
// every other game-code SetTooltip caller in this family draws from —
|
// skin every other game-code SetTooltip caller in this family draws
|
||||||
// as the best-evidenced inference for the unrecoverable constant.
|
// from — as the best-evidenced inference for the unrecoverable
|
||||||
|
// constant.
|
||||||
|
|
||||||
/// <summary>Same popup skin every UIItem prototype resolves to
|
/// <summary>
|
||||||
/// (<see cref="UiItemSlot"/>'s own <c>ItemTooltipRootElementId</c>) —
|
/// The shared popup-skin locator pair every tooltip-bearing surface
|
||||||
/// see this section's own doc note on why the exact value cannot be
|
/// that authors no locator of its own resolves to. Retail's shared
|
||||||
/// read off an authored <c>UIElement_SmartBoxWrapper</c> ElementDesc.</summary>
|
/// UIItem cell-template catalog (<c>ItemListCellTemplate.CatalogLayoutId</c>,
|
||||||
private const uint WorldPopupRootElementId = 0x10000395u;
|
/// LayoutDesc <c>0x21000041</c>) authors the SAME
|
||||||
private const uint WorldPopupLayoutDid = 0x21000041u;
|
/// <c>P0x47=0x10000395</c>/<c>P0x48=0x21000041</c> pair on all 49 of
|
||||||
|
/// its standalone item-cell prototypes (live-DAT-probed 2026-08-16:
|
||||||
|
/// inventory's 32x32 cell, the toolbar's per-slot prototypes, the
|
||||||
|
/// container cell, every paperdoll/armor slot skin —
|
||||||
|
/// <c>TooltipLiveDatTests.PopupSkinRootIds</c>/
|
||||||
|
/// <c>UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator</c>) —
|
||||||
|
/// one of the four 30x30 popup skins this presenter mounts for every
|
||||||
|
/// authored tooltip-bearing element too.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Night-round review F10: previously duplicated as three separate
|
||||||
|
/// private constants with three separate partial citations — this
|
||||||
|
/// class's own world-hover popup (below), <c>UiItemSlot</c>'s item-cell
|
||||||
|
/// popup, and <c>MapPageController</c>'s town-marker popup. All three
|
||||||
|
/// consumers now reference these SAME two constants; this is the ONE
|
||||||
|
/// citation. This class is the natural owner since it's the mount
|
||||||
|
/// point every one of the three consumers ultimately routes through
|
||||||
|
/// (<c>OnTooltipShow</c>/<c>UpdateWorldHoverTooltip</c> both call
|
||||||
|
/// <c>TryBuildAndMountPopup</c> with these values or a widget's own).
|
||||||
|
/// </remarks>
|
||||||
|
public const uint SharedPopupSkinRootElementId = 0x10000395u;
|
||||||
|
public const uint SharedPopupSkinLayoutDid = 0x21000041u;
|
||||||
|
|
||||||
private uint _worldHoverGuid;
|
private uint _worldHoverGuid;
|
||||||
private bool _worldTooltipShowing;
|
private bool _worldTooltipShowing;
|
||||||
|
|
@ -353,12 +400,23 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
return; // no change -> RecvNotice_SmartBoxObjectFound never re-fires
|
return; // no change -> RecvNotice_SmartBoxObjectFound never re-fires
|
||||||
_worldHoverGuid = found;
|
_worldHoverGuid = found;
|
||||||
|
|
||||||
|
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1):
|
||||||
|
// every found-object edge — whether to a DIFFERENT object or to
|
||||||
|
// none at all — tears down whatever world popup is currently up
|
||||||
|
// FIRST, mirroring OnTooltipShow's own unconditional RemovePopup() at
|
||||||
|
// its top. The pre-fix code only cleared on the found==0u edge, so an
|
||||||
|
// A-found-B transition (walking past a run of NPCs/doors/lifestones
|
||||||
|
// with never a frame of "nothing found" between them) called
|
||||||
|
// TryBuildAndMountPopup again with the OLD popup still mounted as a
|
||||||
|
// child of _host — only the _popupRoot reference got overwritten, so
|
||||||
|
// every previous popup was orphaned in the tree and never removed.
|
||||||
|
// _popupRoot is a single field by design (retail's own single
|
||||||
|
// m_pTooltipElement slot); this restores that single-slot invariant.
|
||||||
|
if (_worldTooltipShowing)
|
||||||
|
RemovePopup();
|
||||||
|
|
||||||
if (found == 0u)
|
if (found == 0u)
|
||||||
{
|
|
||||||
if (_worldTooltipShowing)
|
|
||||||
RemovePopup();
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
if (WorldTooltipsEnabled?.Invoke() != true)
|
if (WorldTooltipsEnabled?.Invoke() != true)
|
||||||
return;
|
return;
|
||||||
|
|
@ -370,7 +428,7 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
// A UI-element popup cannot be showing here: UiRoot's own hover
|
// A UI-element popup cannot be showing here: UiRoot's own hover
|
||||||
// (queried above) is null whenever this branch runs, so its dwell
|
// (queried above) is null whenever this branch runs, so its dwell
|
||||||
// timer never arms and OnTooltipShow never fires concurrently.
|
// timer never arms and OnTooltipShow never fires concurrently.
|
||||||
if (TryBuildAndMountPopup(WorldPopupRootElementId, WorldPopupLayoutDid, text))
|
if (TryBuildAndMountPopup(SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, text))
|
||||||
_worldTooltipShowing = true;
|
_worldTooltipShowing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -563,11 +563,145 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
|
|
||||||
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();
|
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30. Enabled and
|
||||||
|
/// TooltipText are retail's SAME state machine (SetState + SetTooltip
|
||||||
|
/// side by side throughout that function) — porting the tooltip text
|
||||||
|
/// without correcting Enabled to match would let the tooltip promise an
|
||||||
|
/// action the button doesn't actually allow (TS-85).
|
||||||
|
/// </summary>
|
||||||
private void UpdateCastAvailability()
|
private void UpdateCastAvailability()
|
||||||
=> _cast.Enabled = _endowmentSelected[_activeTab]
|
{
|
||||||
? _endowmentItemId != 0u
|
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
|
||||||
: _selected[_activeTab] is uint spellId
|
{
|
||||||
&& _casting.IsTargetReady(spellId);
|
(bool enabled, string? tooltip) = ComputeEndowmentCastState();
|
||||||
|
_cast.Enabled = enabled;
|
||||||
|
_cast.TooltipText = tooltip;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selected[_activeTab] is uint spellId)
|
||||||
|
{
|
||||||
|
(bool enabled, string? tooltip) = ComputeSpellCastState(spellId);
|
||||||
|
_cast.Enabled = enabled;
|
||||||
|
_cast.TooltipText = tooltip;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cast.Enabled = false;
|
||||||
|
bool anyFavorites = false;
|
||||||
|
for (int tab = 0; tab < 8 && !anyFavorites; tab++)
|
||||||
|
anyFavorites = _spellbook.GetFavorites(tab).Count > 0;
|
||||||
|
// Verbatim literals: "Select a spell to cast" @ data_7b64ec,
|
||||||
|
// "You have no spells ready to cast" @ data_7b6520.
|
||||||
|
_cast.TooltipText = anyFavorites
|
||||||
|
? "Select a spell to cast"
|
||||||
|
: "You have no spells ready to cast";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30's endowment-item
|
||||||
|
/// branch (<c>m_endowmentItemID != 0</c>). Every literal below is directly
|
||||||
|
/// visible in the decomp (not the mislabeled-vtable-slot class the
|
||||||
|
/// plain-spell branch hits above): <c>"USE the %s"</c> @ data_7b64c0,
|
||||||
|
/// <c>"You must select a target for the %s"</c> @ data_7b6478,
|
||||||
|
/// <c>" on %s"</c> @ data_7b6464. <c>ItemUses::IsUseable_SelfTarget @
|
||||||
|
/// 0x004fcd30</c> is exactly <see cref="ItemUseability.AllowsSelfTarget"/>
|
||||||
|
/// (both test the target-mask Self bit after shifting the high word down
|
||||||
|
/// 16). NOT ported: the incompatible-target sub-state (<c>"You must select
|
||||||
|
/// an appropriate\ntarget for the %s"</c> @ data_7b6400), which retail
|
||||||
|
/// derives from <c>ItemHolder::TargetCompatibleWithObject @ 0x00587520</c>
|
||||||
|
/// — a ~400-line function with its own chat-message side effects, out of
|
||||||
|
/// scope for a tooltip batch. A present target is optimistically treated
|
||||||
|
/// as compatible here, same text as the confirmed-compatible case. See
|
||||||
|
/// TS-85.
|
||||||
|
/// </summary>
|
||||||
|
private (bool enabled, string? tooltip) ComputeEndowmentCastState()
|
||||||
|
{
|
||||||
|
ClientObject? endowment = _objects.Get(_endowmentItemId);
|
||||||
|
if (endowment is null)
|
||||||
|
return (false, null);
|
||||||
|
|
||||||
|
string composedName = ComposeEndowmentName(endowment);
|
||||||
|
if (ItemUseability.AllowsSelfTarget(endowment.Useability ?? 0u))
|
||||||
|
return (true, $"USE the {composedName}");
|
||||||
|
|
||||||
|
uint? targetId = _selection.SelectedObjectId;
|
||||||
|
if (targetId is null or 0u)
|
||||||
|
return (false, $"You must select a target for the {composedName}");
|
||||||
|
|
||||||
|
string targetName = _objects.Get(targetId.Value)?.GetAppropriateName() ?? composedName;
|
||||||
|
return (true, $"USE the {composedName} on {targetName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Night-round review F4: the vararg to <c>"USE the %s"</c> (and both
|
||||||
|
/// select-target strings above) is NOT the bare item name — retail
|
||||||
|
/// builds <c>"%s (%hs)"</c> @0x7b64d8 (item name, spell name) once
|
||||||
|
/// at <c>@0x004c6bb6-ef</c> and reuses that composed string as the
|
||||||
|
/// shared operand for all three format strings (byte-confirmed: the
|
||||||
|
/// three sprintf call sites at <c>0x4c6c7f</c>/<c>0x4c6ca4</c>/
|
||||||
|
/// <c>0x4c6d46</c> all read the SAME <c>[esp+0x18]</c> slot). e.g.
|
||||||
|
/// "USE the Lightning Wand (Lightning Bolt VI)".
|
||||||
|
/// </summary>
|
||||||
|
private string ComposeEndowmentName(ClientObject endowment)
|
||||||
|
{
|
||||||
|
string itemName = endowment.GetAppropriateName();
|
||||||
|
return _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata spellMetadata)
|
||||||
|
? $"{itemName} ({spellMetadata.Name})"
|
||||||
|
: itemName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30</c>'s
|
||||||
|
/// plain-spell branch (<c>m_endowmentItemID == 0</c>, a spell is
|
||||||
|
/// highlighted in the open submenu). Night-round review F3 corrects
|
||||||
|
/// TS-85's "cannot be recovered" claim: the three format strings TS-85
|
||||||
|
/// took for gmNoticeHandler vtable-slot mislabels (a real BN artifact
|
||||||
|
/// class, but not what happened here) are recoverable literals once
|
||||||
|
/// the raw machine code is disassembled directly — the vtable-slot
|
||||||
|
/// names Binary Ninja printed for the <c>sprintf</c> calls were spurious.
|
||||||
|
/// Byte-confirmed pushes: <c>"CAST %hs"</c> @0x7b63a4 at both
|
||||||
|
/// <c>0x4c6f5d</c> (untargeted/self-cast, always enabled) and
|
||||||
|
/// <c>0x4c6ea4</c> (targeted+compatible, enabled, then <c>" on %s"</c>
|
||||||
|
/// @0x7b6464 appended with the target's name at <c>0x4c6ee8</c>);
|
||||||
|
/// <c>"You must select an appropriate target for %hs"</c> @0x7b6348 at
|
||||||
|
/// <c>0x4c6f18</c> (targeted+incompatible, stays disabled); <c>"You
|
||||||
|
/// must select a target for %hs"</c> @0x7b63b8 at <c>0x4c6e48</c> (no
|
||||||
|
/// target selected, stays disabled). <c>%hs</c> is the spell's own
|
||||||
|
/// name in every case (<c>CSpellBase::InqName</c>, the same call
|
||||||
|
/// (<c>0x5bbee0</c>) at all four sites) — no item/composed name
|
||||||
|
/// involved here, unlike the endowment branch above.
|
||||||
|
/// </summary>
|
||||||
|
private (bool enabled, string? tooltip) ComputeSpellCastState(uint spellId)
|
||||||
|
{
|
||||||
|
if (!_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
|
||||||
|
return (false, null);
|
||||||
|
|
||||||
|
string spellName = metadata.Name;
|
||||||
|
SpellCastGate gate = _casting.EvaluateCastGate(spellId);
|
||||||
|
switch (gate)
|
||||||
|
{
|
||||||
|
case SpellCastGate.NoTargetNeeded:
|
||||||
|
return (true, $"CAST {spellName}");
|
||||||
|
case SpellCastGate.TargetCompatible:
|
||||||
|
{
|
||||||
|
uint? targetId = _selection.SelectedObjectId;
|
||||||
|
string? targetName = targetId is uint id and not 0u
|
||||||
|
? _objects.Get(id)?.GetAppropriateName()
|
||||||
|
: null;
|
||||||
|
return (true, targetName is null
|
||||||
|
? $"CAST {spellName}"
|
||||||
|
: $"CAST {spellName} on {targetName}");
|
||||||
|
}
|
||||||
|
case SpellCastGate.TargetIncompatible:
|
||||||
|
return (false, $"You must select an appropriate target for {spellName}");
|
||||||
|
case SpellCastGate.NoTargetSelected:
|
||||||
|
return (false, $"You must select a target for {spellName}");
|
||||||
|
default:
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ConfigureSpellName()
|
private void ConfigureSpellName()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,21 @@ public static class RetailPanelCatalog
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const uint SocialPanel = 12u;
|
public const uint SocialPanel = 12u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
|
||||||
|
/// panel's <c>gmPanelUI</c> slot key — byte-verified from the live
|
||||||
|
/// installed DATs (host <c>0x2100006E</c> slot <c>0x1000018C</c>'s own
|
||||||
|
/// authored <c>0x10000029 = 16</c>, and the toolbar Map/House button
|
||||||
|
/// <c>0x1000019A</c>'s own authored <c>0x10000029 = 16</c>,
|
||||||
|
/// <c>MapHousePanelSlotProbeTests</c>). Already independently named by
|
||||||
|
/// the FA campaign's full 16-slot dump
|
||||||
|
/// (<c>docs/research/2026-08-11-fa-panel-structure.md:927-933</c>,
|
||||||
|
/// "gmMapUI+gmHouseUI pages"). Unlike <see cref="SocialPanel"/>, a real
|
||||||
|
/// toolbar button opens this one — it is in BOTH <see cref="Mounted"/>
|
||||||
|
/// and <see cref="Toolbar"/>.
|
||||||
|
/// </summary>
|
||||||
|
public const uint MapHouse = 16u;
|
||||||
|
|
||||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||||
{
|
{
|
||||||
(CharacterInformation, WindowNames.CharacterInformation),
|
(CharacterInformation, WindowNames.CharacterInformation),
|
||||||
|
|
@ -55,6 +70,7 @@ public static class RetailPanelCatalog
|
||||||
(Vitae, WindowNames.Vitae),
|
(Vitae, WindowNames.Vitae),
|
||||||
(Options, WindowNames.Options),
|
(Options, WindowNames.Options),
|
||||||
(SocialPanel, WindowNames.SocialPanel),
|
(SocialPanel, WindowNames.SocialPanel),
|
||||||
|
(MapHouse, WindowNames.MapHouse),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||||
|
|
@ -63,6 +79,7 @@ public static class RetailPanelCatalog
|
||||||
(Character, WindowNames.Character),
|
(Character, WindowNames.Character),
|
||||||
(Magic, WindowNames.Spellbook),
|
(Magic, WindowNames.Spellbook),
|
||||||
(Options, WindowNames.Options),
|
(Options, WindowNames.Options),
|
||||||
|
(MapHouse, WindowNames.MapHouse),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
|
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,20 @@ public sealed record SocialRuntimeBindings(
|
||||||
// Trailing/optional per the established compatibility convention.
|
// Trailing/optional per the established compatibility convention.
|
||||||
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
|
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch C (overnight hover/UI round, 2026-08-17): bindings for the
|
||||||
|
/// two-tab Map/House panel. <see cref="HousePosition"/> defaults to
|
||||||
|
/// "no house" and <see cref="HouseLines"/> to empty when the caller doesn't
|
||||||
|
/// wire the House wire groundwork — the panel still mounts and the Map tab
|
||||||
|
/// still works standalone.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MapHouseRuntimeBindings(
|
||||||
|
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar,
|
||||||
|
Func<uint> PlayerCellId,
|
||||||
|
Func<CreateObject.ServerPosition?>? HousePosition = null,
|
||||||
|
Func<IReadOnlyList<string>>? HouseLines = null,
|
||||||
|
Action? HouseShown = null);
|
||||||
|
|
||||||
public sealed record InventoryRuntimeBindings(
|
public sealed record InventoryRuntimeBindings(
|
||||||
ClientObjectTable Objects,
|
ClientObjectTable Objects,
|
||||||
Func<uint> PlayerGuid,
|
Func<uint> PlayerGuid,
|
||||||
|
|
@ -451,6 +465,7 @@ public sealed record RetailUiRuntimeBindings(
|
||||||
AppraisalRuntimeBindings Appraisal,
|
AppraisalRuntimeBindings Appraisal,
|
||||||
OptionsRuntimeBindings Options,
|
OptionsRuntimeBindings Options,
|
||||||
SocialRuntimeBindings Social,
|
SocialRuntimeBindings Social,
|
||||||
|
MapHouseRuntimeBindings MapHouse,
|
||||||
StackSplitQuantityState StackSplitQuantity,
|
StackSplitQuantityState StackSplitQuantity,
|
||||||
BufferedUiRegistry? Plugins,
|
BufferedUiRegistry? Plugins,
|
||||||
RetailUiPersistenceBindings? Persistence,
|
RetailUiPersistenceBindings? Persistence,
|
||||||
|
|
@ -542,6 +557,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
MountDialogFactory();
|
MountDialogFactory();
|
||||||
MountTooltipPresenter();
|
MountTooltipPresenter();
|
||||||
MountSocialPanel();
|
MountSocialPanel();
|
||||||
|
MountMapHousePanel();
|
||||||
MountCharacter();
|
MountCharacter();
|
||||||
MountPlugins();
|
MountPlugins();
|
||||||
MountInventory();
|
MountInventory();
|
||||||
|
|
@ -650,6 +666,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
public VendorUiController? VendorController { get; private set; }
|
public VendorUiController? VendorController { get; private set; }
|
||||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||||
public SocialPanelController? SocialPanelController { get; private set; }
|
public SocialPanelController? SocialPanelController { get; private set; }
|
||||||
|
public MapHousePanelController? MapHousePanelController { get; private set; }
|
||||||
internal CharacterManagementUiController? CharacterManagementController =>
|
internal CharacterManagementUiController? CharacterManagementController =>
|
||||||
_characterManagementMount?.Controller;
|
_characterManagementMount?.Controller;
|
||||||
internal CharacterCreationUiController? CharacterCreationController =>
|
internal CharacterCreationUiController? CharacterCreationController =>
|
||||||
|
|
@ -822,6 +839,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
SelectedObjectController?.Tick(deltaSeconds);
|
SelectedObjectController?.Tick(deltaSeconds);
|
||||||
ExternalContainerController?.Tick();
|
ExternalContainerController?.Tick();
|
||||||
SocialPanelController?.Tick();
|
SocialPanelController?.Tick();
|
||||||
|
MapHousePanelController?.Tick(deltaSeconds);
|
||||||
_itemCooldownController?.Tick();
|
_itemCooldownController?.Tick();
|
||||||
_characterManagementMount?.Tick();
|
_characterManagementMount?.Tick();
|
||||||
CharacterManagementController?.Tick();
|
CharacterManagementController?.Tick();
|
||||||
|
|
@ -3256,6 +3274,141 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
|
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
|
||||||
|
/// panel — host <c>0x2100006E</c> slot <c>0x1000018C</c>,
|
||||||
|
/// <see cref="RetailPanelCatalog.MapHouse"/> id 16. Same import/Build/Bind
|
||||||
|
/// recipe as <see cref="MountSocialPanel"/>. The House tab's ListBox rows
|
||||||
|
/// resolve through its own authored template directly via
|
||||||
|
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/> (a FIXED
|
||||||
|
/// single template, unlike Friends/Squelch/Fellowship's live-roster
|
||||||
|
/// row families) — the <see cref="Layout.RowTemplateResolver"/> below
|
||||||
|
/// serves only the Map tab's per-town hotspot template.
|
||||||
|
/// </summary>
|
||||||
|
private void MountMapHousePanel()
|
||||||
|
{
|
||||||
|
ElementInfo? rootInfo;
|
||||||
|
ImportedLayout? layout;
|
||||||
|
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
{
|
||||||
|
rootInfo = LayoutImporter.ImportInfos(
|
||||||
|
_bindings.Assets.Dats,
|
||||||
|
Layout.MapHousePanelController.HostLayoutId,
|
||||||
|
Layout.MapHousePanelController.SlotElementId);
|
||||||
|
layout = rootInfo is null
|
||||||
|
? null
|
||||||
|
: LayoutImporter.Build(
|
||||||
|
rootInfo,
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
|
_bindings.Assets.DefaultFont,
|
||||||
|
_bindings.Assets.ResolveFont,
|
||||||
|
strings.Resolve);
|
||||||
|
}
|
||||||
|
if (rootInfo is null || layout is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[UI] Map/House panel: LayoutDesc 0x2100006E slot 0x1000018C not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The town-hotspot template (m_pMap's own 0x47/0x48 attrs) is
|
||||||
|
// resolved once and cached — same "resolve the ElementInfo once,
|
||||||
|
// Build a fresh UiElement per call" shape RowTemplateResolver uses
|
||||||
|
// for the social panel's row families.
|
||||||
|
var hotspotTemplate = new Layout.RowTemplateResolver(
|
||||||
|
(templateLayoutId, templateElementId) => LayoutImporter.ImportInfos(
|
||||||
|
_bindings.Assets.Dats, templateLayoutId, templateElementId),
|
||||||
|
info => LayoutImporter.Build(
|
||||||
|
info,
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
|
_bindings.Assets.DefaultFont,
|
||||||
|
_bindings.Assets.ResolveFont).Root);
|
||||||
|
UiElement? ResolveHotspotTemplate(uint templateLayoutId, uint templateElementId)
|
||||||
|
{
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
return hotspotTemplate.Resolve(templateLayoutId, templateElementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// m_pMap's two button-swallowed icon children (player/house markers)
|
||||||
|
// only exist as ElementInfos INSIDE rootInfo's own panel-slot resolve
|
||||||
|
// tree — a cold ImportInfos(hostLayout, iconId) re-import returns
|
||||||
|
// null on the live DAT (register row AD-108). MapPageController.Bind
|
||||||
|
// locates each icon's info under m_pMap and calls this seam to build
|
||||||
|
// it: the build half of RowTemplateResolver's shape, no import half.
|
||||||
|
// Monitor re-entrancy on DatLock is established for this mount path
|
||||||
|
// (Bind itself runs under the lock below, same as
|
||||||
|
// ResolveHotspotTemplate's own re-entrant take).
|
||||||
|
UiElement? BuildSwallowedIcon(ElementInfo iconInfo)
|
||||||
|
{
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
return LayoutImporter.Build(
|
||||||
|
iconInfo,
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
|
_bindings.Assets.DefaultFont,
|
||||||
|
_bindings.Assets.ResolveFont).Root;
|
||||||
|
}
|
||||||
|
|
||||||
|
MapHouseRuntimeBindings mh = _bindings.MapHouse;
|
||||||
|
var callbacks = new Layout.MapHousePanelController.Callbacks(
|
||||||
|
Toggle: () => ToggleWindow(WindowNames.MapHouse),
|
||||||
|
Map: new Layout.MapPageController.Bindings(
|
||||||
|
CurrentCalendar: mh.CurrentCalendar,
|
||||||
|
PlayerCellId: mh.PlayerCellId,
|
||||||
|
HousePosition: mh.HousePosition ?? (static () => null),
|
||||||
|
TemplateResolver: ResolveHotspotTemplate,
|
||||||
|
IconBuilder: BuildSwallowedIcon),
|
||||||
|
House: new Layout.HousePageController.Bindings(
|
||||||
|
Lines: mh.HouseLines ?? (static () => Array.Empty<string>()),
|
||||||
|
OnShown: mh.HouseShown,
|
||||||
|
// Same generic template resolver the Map tab's town
|
||||||
|
// hotspots use — see HousePageController.Bindings.
|
||||||
|
// TemplateResolver's own doc for why reusing it is correct.
|
||||||
|
TemplateResolver: ResolveHotspotTemplate));
|
||||||
|
|
||||||
|
Layout.MapHousePanelController? controller;
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
controller = Layout.MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||||
|
if (controller is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[UI] Map/House panel: required root did not build as UiTabPanel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.ActivateTabs();
|
||||||
|
MapHousePanelController = controller;
|
||||||
|
|
||||||
|
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||||
|
Host.Root,
|
||||||
|
controller.Root,
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
|
new RetailWindowFrame.Options
|
||||||
|
{
|
||||||
|
WindowName = WindowNames.MapHouse,
|
||||||
|
Chrome = RetailWindowChrome.NineSlice,
|
||||||
|
Left = 240f,
|
||||||
|
Top = 160f,
|
||||||
|
Visible = false,
|
||||||
|
ResizeX = false,
|
||||||
|
ResizeY = false,
|
||||||
|
ConstrainDragToParent = true,
|
||||||
|
ConstrainResizeToParent = true,
|
||||||
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
||||||
|
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||||
|
ContentClickThrough = false,
|
||||||
|
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
|
||||||
|
Controller = controller,
|
||||||
|
});
|
||||||
|
_panelUi.RegisterMainPanel(
|
||||||
|
RetailPanelCatalog.MapHouse,
|
||||||
|
WindowNames.MapHouse,
|
||||||
|
handle,
|
||||||
|
rootInfo.TryGetEffectiveBool(
|
||||||
|
RetailPanelUiController.RestorePreviousPropertyId,
|
||||||
|
out bool restorePrevious)
|
||||||
|
&& restorePrevious);
|
||||||
|
Console.WriteLine("[UI] retail Map/House panel from LayoutDesc importer (0x2100006E slot 0x1000018C).");
|
||||||
|
}
|
||||||
|
|
||||||
private void MountDialogFactory()
|
private void MountDialogFactory()
|
||||||
{
|
{
|
||||||
if (DialogFactory is not null)
|
if (DialogFactory is not null)
|
||||||
|
|
|
||||||
|
|
@ -19,25 +19,23 @@ public class UiItemSlot : UiElement
|
||||||
/// live-DAT-probed 2026-08-16: every top-level catalog child (inventory's
|
/// live-DAT-probed 2026-08-16: every top-level catalog child (inventory's
|
||||||
/// 32x32 cell <c>0x1000033A</c>, the toolbar's per-slot prototypes
|
/// 32x32 cell <c>0x1000033A</c>, the toolbar's per-slot prototypes
|
||||||
/// <c>0x1000043B</c>.., the container cell <c>0x1000033F</c>, and every
|
/// <c>0x1000043B</c>.., the container cell <c>0x1000033F</c>, and every
|
||||||
/// paperdoll/armor slot skin alike) resolves <c>P0x47=0x10000395</c> /
|
/// paperdoll/armor slot skin alike) resolves through catalog inheritance
|
||||||
/// <c>P0x48=0x21000041</c> through catalog inheritance, matching one of
|
/// to <see cref="Layout.RetailTooltipPresenter.SharedPopupSkinRootElementId"/>/
|
||||||
/// the four popup skins <see cref="Layout.RetailTooltipPresenter"/> already
|
/// <see cref="Layout.RetailTooltipPresenter.SharedPopupSkinLayoutDid"/> —
|
||||||
/// mounts for every other tooltip-bearing element
|
/// see that class for the single canonical citation (night-round review
|
||||||
/// (<c>Layout.TooltipLiveDatTests.PopupSkinRootIds</c>). Since
|
/// F10 consolidated what used to be three separately-cited copies of the
|
||||||
/// <see cref="UiItemSlot"/> cells are built programmatically (never through
|
/// same pair into one). Since <see cref="UiItemSlot"/> cells are built
|
||||||
/// <c>LayoutImporter.Build</c>), this port hardcodes the uniform pair here
|
/// programmatically (never through <c>LayoutImporter.Build</c>), this
|
||||||
/// rather than re-deriving it per instance — the same "exhaustive scan,
|
/// port hardcodes the uniform pair here rather than re-deriving it per
|
||||||
/// then hardcode" shape as <c>RetailCursorCatalog</c>'s five window-control
|
/// instance — the same "exhaustive scan, then hardcode" shape as
|
||||||
/// cursor DIDs and <c>ItemListCellTemplate.CatalogLayoutId</c> itself.
|
/// <c>RetailCursorCatalog</c>'s five window-control cursor DIDs and
|
||||||
|
/// <c>ItemListCellTemplate.CatalogLayoutId</c> itself.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const uint ItemTooltipRootElementId = 0x10000395u;
|
|
||||||
private const uint ItemTooltipLayoutDid = 0x21000041u;
|
|
||||||
|
|
||||||
public UiItemSlot()
|
public UiItemSlot()
|
||||||
{
|
{
|
||||||
ClickThrough = false;
|
ClickThrough = false;
|
||||||
AuthoredTooltipRootElementId = ItemTooltipRootElementId;
|
AuthoredTooltipRootElementId = Layout.RetailTooltipPresenter.SharedPopupSkinRootElementId;
|
||||||
AuthoredTooltipLayoutDid = ItemTooltipLayoutDid;
|
AuthoredTooltipLayoutDid = Layout.RetailTooltipPresenter.SharedPopupSkinLayoutDid;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool ConsumesDatChildren => true;
|
public override bool ConsumesDatChildren => true;
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,20 @@ public class UiClickablePanel : UiPanel
|
||||||
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
|
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
|
||||||
public float SelectionBarHeight { get; set; } = 3f;
|
public float SelectionBarHeight { get; set; } = 3f;
|
||||||
|
|
||||||
|
/// <summary>Settable tooltip, surfaced through the shared
|
||||||
|
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
|
||||||
|
/// <see cref="UiButton.TooltipText"/> / <see cref="UiCatalogSlot"/>). TS-85's
|
||||||
|
/// character-panel gap: retail's <c>AttributeInfoRegion</c> /
|
||||||
|
/// <c>Attribute2ndInfoRegion</c> / <c>SkillInfoRegion</c> row constructors
|
||||||
|
/// (<c>UIElement::SetTooltip</c> at 0x004f1617 / 0x004f1777 / 0x004f222f) stamp
|
||||||
|
/// this once per row at construction — retail never updates it afterward, so a
|
||||||
|
/// plain settable string (not a live provider) matches.</summary>
|
||||||
|
public string? TooltipText { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string? GetTooltipText() =>
|
||||||
|
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
||||||
|
|
||||||
public UiClickablePanel()
|
public UiClickablePanel()
|
||||||
{
|
{
|
||||||
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
|
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,42 @@ public sealed class UiTemplateListBox : UiDatElement
|
||||||
Height = Height,
|
Height = Height,
|
||||||
};
|
};
|
||||||
base.AddChild(_viewport);
|
base.AddChild(_viewport);
|
||||||
|
|
||||||
|
// #412-class fix (2026-08-16, overnight hover/UI round, Batch A bug 2):
|
||||||
|
// #372's seed above only fixed the 0×0 collapse for a ListBox whose OWN
|
||||||
|
// size never changes after the viewport is created. It does NOT hold for
|
||||||
|
// the Options panel's real mount: this ListBox (0x10000200 etc.) is a
|
||||||
|
// DAT-imported element carrying its own retail four-edge UiLayoutPolicy
|
||||||
|
// (UIElement::UpdateForParentSizeChange @0x00462640), and a page
|
||||||
|
// controller's Bind (which lazily creates this viewport, calling
|
||||||
|
// AddItemFromTemplateList) runs BEFORE the tree's first real draw frame —
|
||||||
|
// i.e. before ANY ApplyAnchor pass has ever run. The Options tab-host's
|
||||||
|
// page slot (298×575 authored) is taller than its actual 300×362 mounted
|
||||||
|
// container, so on the FIRST draw frame the slot's LayoutPolicy shrinks
|
||||||
|
// it top-down (e.g. to ~298×337), and THIS ListBox — also LayoutPolicy-
|
||||||
|
// driven, recomputed fresh every call, no capture-staleness of its own —
|
||||||
|
// shrinks right behind it (e.g. to ~282×297) in the SAME frame, BEFORE
|
||||||
|
// its per-child loop ever reaches the viewport below it. The viewport
|
||||||
|
// above was seeded at BIND time against the ListBox's PRE-shrink size
|
||||||
|
// (276×560) but its own legacy Left|Top|Right|Bottom anchor baseline is
|
||||||
|
// only CAPTURED lazily, on ITS first ApplyAnchor call — which lands AFTER
|
||||||
|
// the ListBox has already shrunk in that same frame. That capture then
|
||||||
|
// measures a NEGATIVE bottom margin (parentH(297) - (0+560) = -263) which
|
||||||
|
// ComputeAnchoredRect's stretch math preserves forever (h = parentH - mB -
|
||||||
|
// mT = 297 - (-263) - 0 = 560): the viewport is permanently locked at its
|
||||||
|
// ORIGINAL oversized height, clipping its rows to a bound retail never
|
||||||
|
// actually gave it on screen. Every row past the real ~297px stays
|
||||||
|
// "visible" per LayoutScrollableChildren's cull test and paints straight
|
||||||
|
// through the footer and past the window's real bottom edge — the exact
|
||||||
|
// "dozens of rows below the window frame" symptom (#412-class report:
|
||||||
|
// Full Screen/Sync/Screen Brightness/Adaptive Degrade/quality dropdowns
|
||||||
|
// drawing outside the panel). Forcing the capture to happen NOW, while
|
||||||
|
// Width/Height still exactly equal the ListBox's CURRENT (pre-shrink, but
|
||||||
|
// zero-margin) size, makes the captured margins (0,0,0,0) instead of
|
||||||
|
// negative — ComputeAnchoredRect then tracks whatever height the ListBox
|
||||||
|
// ACTUALLY ends up at after its own LayoutPolicy runs, on every frame
|
||||||
|
// after this one, exactly like #372 intended.
|
||||||
|
_viewport.CaptureCurrentAnchorBaseline();
|
||||||
}
|
}
|
||||||
return _viewport;
|
return _viewport;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,8 @@ public static class WindowNames
|
||||||
/// <summary>Campaign FA slice FA3: the four-tab Friends/Allegiance/
|
/// <summary>Campaign FA slice FA3: the four-tab Friends/Allegiance/
|
||||||
/// Fellowship/Squelch panel (<see cref="RetailPanelCatalog.SocialPanel"/>).</summary>
|
/// Fellowship/Squelch panel (<see cref="RetailPanelCatalog.SocialPanel"/>).</summary>
|
||||||
public const string SocialPanel = "social-panel";
|
public const string SocialPanel = "social-panel";
|
||||||
|
|
||||||
|
/// <summary>Batch C (overnight hover/UI round): the two-tab Map/House
|
||||||
|
/// panel (<see cref="RetailPanelCatalog.MapHouse"/>).</summary>
|
||||||
|
public const string MapHouse = "map-house";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,14 @@ public static class GameEventWiring
|
||||||
Action<uint /*whoDeclined*/>? onTradeDecline = null,
|
Action<uint /*whoDeclined*/>? onTradeDecline = null,
|
||||||
Action<uint /*whoReset*/>? onTradeReset = null,
|
Action<uint /*whoReset*/>? onTradeReset = null,
|
||||||
Action<GameEvents.TradeFailure>? onTradeFailure = null,
|
Action<GameEvents.TradeFailure>? onTradeFailure = null,
|
||||||
Action? onTradeClearAcceptance = null)
|
Action? onTradeClearAcceptance = null,
|
||||||
|
// House panel (Batch C, Map/House toolbar panel, 2026-08-17): the
|
||||||
|
// same Runtime-owned delegate-hole shape as trade above —
|
||||||
|
// RuntimeHouseState (or a lighter equivalent) is the consumer.
|
||||||
|
Action<GameEvents.HouseData>? onHouseData = null,
|
||||||
|
Action<uint /*weenieError*/>? onHouseStatus = null,
|
||||||
|
Action<uint /*rentTime*/>? onHouseUpdateRentTime = null,
|
||||||
|
Action<IReadOnlyList<GameEvents.HousePayment>>? onHouseUpdateRentPayment = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||||
ArgumentNullException.ThrowIfNull(items);
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
@ -402,6 +409,43 @@ public static class GameEventWiring
|
||||||
onTradeClearAcceptance());
|
onTradeClearAcceptance());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── House panel (0x0225–0x0228) ───────────────────────────
|
||||||
|
// Batch C (Map/House toolbar panel, 2026-08-17). gmHouseUI::
|
||||||
|
// PostInit registers all four; consumers are optional so every
|
||||||
|
// existing caller compiles unchanged.
|
||||||
|
if (onHouseData is not null)
|
||||||
|
{
|
||||||
|
registrar.Register(GameEventType.HouseData, e =>
|
||||||
|
{
|
||||||
|
var p = GameEvents.ParseHouseData(e.Payload.Span);
|
||||||
|
if (p is not null) onHouseData(p.Value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onHouseStatus is not null)
|
||||||
|
{
|
||||||
|
registrar.Register(GameEventType.HouseStatus, e =>
|
||||||
|
{
|
||||||
|
var p = GameEvents.ParseHouseStatus(e.Payload.Span);
|
||||||
|
if (p is not null) onHouseStatus(p.Value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onHouseUpdateRentTime is not null)
|
||||||
|
{
|
||||||
|
registrar.Register(GameEventType.UpdateRentTime, e =>
|
||||||
|
{
|
||||||
|
var p = GameEvents.ParseUpdateRentTime(e.Payload.Span);
|
||||||
|
if (p is not null) onHouseUpdateRentTime(p.Value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onHouseUpdateRentPayment is not null)
|
||||||
|
{
|
||||||
|
registrar.Register(GameEventType.UpdateRentPayment, e =>
|
||||||
|
{
|
||||||
|
var p = GameEvents.ParseUpdateRentPayment(e.Payload.Span);
|
||||||
|
if (p is not null) onHouseUpdateRentPayment(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (onConfirmationRequest is not null)
|
if (onConfirmationRequest is not null)
|
||||||
{
|
{
|
||||||
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>
|
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,17 @@ public static class ClientCommandRequests
|
||||||
public const uint AddPlayerPermissionOpcode = 0x0219u;
|
public const uint AddPlayerPermissionOpcode = 0x0219u;
|
||||||
public const uint RemovePlayerPermissionOpcode = 0x021Au;
|
public const uint RemovePlayerPermissionOpcode = 0x021Au;
|
||||||
public const uint AbandonHouseOpcode = 0x021Fu;
|
public const uint AbandonHouseOpcode = 0x021Fu;
|
||||||
|
// Batch C (Map/House toolbar panel, 2026-08-17): the query the House
|
||||||
|
// tab needs to populate. ACE GameActionHouseQuery.cs: [GameAction(
|
||||||
|
// GameActionType.HouseQuery)] (0x021E), Handle reads no payload and
|
||||||
|
// calls session.Player.HandleActionQueryHouse() — which replies with
|
||||||
|
// either GameEventHouseStatus (0x0226, no house owned) or
|
||||||
|
// GameEventHouseData (0x0225, house owned). No known retail client
|
||||||
|
// call site was found in this session's decomp reading (gmHouseUI::
|
||||||
|
// PostInit never sends it) — HousePageController fires it when the
|
||||||
|
// House tab becomes visible, an acdream convention, not a ported
|
||||||
|
// retail trigger.
|
||||||
|
public const uint HouseQueryOpcode = 0x021Eu;
|
||||||
|
|
||||||
// Named-retail anchors:
|
// Named-retail anchors:
|
||||||
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
|
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
|
||||||
|
|
@ -282,6 +293,11 @@ public static class ClientCommandRequests
|
||||||
public static byte[] BuildAbandonHouse(uint sequence) =>
|
public static byte[] BuildAbandonHouse(uint sequence) =>
|
||||||
BuildParameterless(sequence, AbandonHouseOpcode);
|
BuildParameterless(sequence, AbandonHouseOpcode);
|
||||||
|
|
||||||
|
// Queries the local player's house info (owned house data, or a
|
||||||
|
// no-house status) — GameActionHouseQuery.Handle: no payload read.
|
||||||
|
public static byte[] BuildHouseQuery(uint sequence) =>
|
||||||
|
BuildParameterless(sequence, HouseQueryOpcode);
|
||||||
|
|
||||||
private static byte[] BuildParameterless(uint sequence, uint opcode)
|
private static byte[] BuildParameterless(uint sequence, uint opcode)
|
||||||
{
|
{
|
||||||
byte[] body = new byte[12];
|
byte[] body = new byte[12];
|
||||||
|
|
|
||||||
|
|
@ -1003,6 +1003,126 @@ public static class GameEvents
|
||||||
Guests: guests));
|
Guests: guests));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── House panel (Batch C, Map/House toolbar panel, 2026-08-17) ─────────
|
||||||
|
// gmHouseUI::PostInit @0x004a2710 registers notice handlers for wire
|
||||||
|
// opcodes 0x0225-0x0228; the recon doc (docs/research/2026-08-17-map-
|
||||||
|
// house-recon.md) is the SSOT for the retail-side call sites and the
|
||||||
|
// two ACE writer stubs (UpdateRentTime always writes 0u; UpdateRentPayment
|
||||||
|
// always writes an empty list — captured verbatim below, not guessed).
|
||||||
|
|
||||||
|
/// <summary>One house purchase/maintenance line item. ACE
|
||||||
|
/// HousePaymentExtensions.Write: Num(int) + Paid(int) + WeenieID(uint) +
|
||||||
|
/// Name(String16L) + PluralName(String16L).</summary>
|
||||||
|
public readonly record struct HousePayment(
|
||||||
|
int Num, int Paid, uint WeenieID, string Name, string PluralName);
|
||||||
|
|
||||||
|
/// <summary>0x0225 HouseData: the owned-house panel snapshot. ACE
|
||||||
|
/// HouseDataExtensions.Write: BuyTime(uint) + RentTime(uint) +
|
||||||
|
/// Type(uint HouseType enum) + MaintenanceFree(uint bool) +
|
||||||
|
/// Buy(List<HousePayment>) + Rent(List<HousePayment>) +
|
||||||
|
/// Position (the same Cell+Pos.XYZ+Rotation.WXYZ 32-byte shape
|
||||||
|
/// <see cref="CreateObject.ServerPosition"/> already parses
|
||||||
|
/// elsewhere).</summary>
|
||||||
|
public readonly record struct HouseData(
|
||||||
|
uint BuyTime,
|
||||||
|
uint RentTime,
|
||||||
|
uint Type,
|
||||||
|
bool MaintenanceFree,
|
||||||
|
IReadOnlyList<HousePayment> Buy,
|
||||||
|
IReadOnlyList<HousePayment> Rent,
|
||||||
|
CreateObject.ServerPosition Position);
|
||||||
|
|
||||||
|
public static HouseData? ParseHouseData(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int pos = 0;
|
||||||
|
if (payload.Length - pos < 16) return null;
|
||||||
|
uint buyTime = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
uint rentTime = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
bool maintenanceFree = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)) != 0; pos += 4;
|
||||||
|
|
||||||
|
List<HousePayment>? buy = ReadHousePaymentList(payload, ref pos);
|
||||||
|
if (buy is null) return null;
|
||||||
|
List<HousePayment>? rent = ReadHousePaymentList(payload, ref pos);
|
||||||
|
if (rent is null) return null;
|
||||||
|
|
||||||
|
if (payload.Length - pos < 32) return null;
|
||||||
|
var position = new CreateObject.ServerPosition(
|
||||||
|
LandblockId: BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos + 0)),
|
||||||
|
PositionX: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 4)),
|
||||||
|
PositionY: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 8)),
|
||||||
|
PositionZ: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 12)),
|
||||||
|
RotationW: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 16)),
|
||||||
|
RotationX: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 20)),
|
||||||
|
RotationY: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 24)),
|
||||||
|
RotationZ: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 28)));
|
||||||
|
|
||||||
|
return new HouseData(buyTime, rentTime, type, maintenanceFree, buy, rent, position);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HousePayment>? ReadHousePaymentList(ReadOnlySpan<byte> payload, ref int pos)
|
||||||
|
{
|
||||||
|
if (payload.Length - pos < 4) return null;
|
||||||
|
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
var list = new List<HousePayment>((int)Math.Min(count, 4096));
|
||||||
|
for (uint i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (payload.Length - pos < 8) return null;
|
||||||
|
int num = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
int paid = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
if (payload.Length - pos < 4) return null;
|
||||||
|
uint weenieId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
string name = ReadString16L(payload, ref pos);
|
||||||
|
string pluralName = ReadString16L(payload, ref pos);
|
||||||
|
list.Add(new HousePayment(num, paid, weenieId, name, pluralName));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0x0226 HouseStatus: a single WeenieError u32 — retail's
|
||||||
|
/// <c>RecvNotice_FailedHouseTransaction</c> family (also the "no house
|
||||||
|
/// owned" reply to a HouseQuery — ACE Player_House.cs
|
||||||
|
/// HandleActionQueryHouse's <c>new GameEventHouseStatus(Session)</c>
|
||||||
|
/// defaults to <c>WeenieError.BadParam</c> (corrected 2026-08-17; an
|
||||||
|
/// earlier note here said <c>WeenieError.None</c>, which is not what
|
||||||
|
/// <c>GameEventHouseStatus</c>'s own constructor default reads). The
|
||||||
|
/// value is moot either way — decomp-confirmed retail's own
|
||||||
|
/// <c>gmHouseUI::Update(uint32_t)</c>/<c>gmMapUI::
|
||||||
|
/// RecvNotice_FailedHouseTransaction</c> never read this field
|
||||||
|
/// (<c>AcDream.Runtime.Gameplay.RuntimeHouseState.ApplyHouseStatus</c>
|
||||||
|
/// accepts and discards it for the same reason).</summary>
|
||||||
|
public static uint? ParseHouseStatus(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
if (payload.Length < 4) return null;
|
||||||
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0x0227 UpdateRentTime: a single uint (when the current
|
||||||
|
/// maintenance period began, Unix timestamp). ACE
|
||||||
|
/// GameEventHouseUpdateRentTime.cs is a STUB that always writes
|
||||||
|
/// <c>0u</c> — captured here for completeness, not exercised by any
|
||||||
|
/// live ACE install today.</summary>
|
||||||
|
public static uint? ParseUpdateRentTime(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
if (payload.Length < 4) return null;
|
||||||
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0x0228 UpdateRentPayment: a List<HousePayment> (the
|
||||||
|
/// rent items and how much of each has been paid this period). ACE
|
||||||
|
/// GameEventHouseUpdateRentPayment.cs is a STUB that always writes an
|
||||||
|
/// EMPTY list — captured here for completeness, not exercised by any
|
||||||
|
/// live ACE install today.</summary>
|
||||||
|
public static IReadOnlyList<HousePayment>? ParseUpdateRentPayment(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
int pos = 0;
|
||||||
|
return ReadHousePaymentList(payload, ref pos);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
|
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
|
||||||
|
|
||||||
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
|
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
|
||||||
|
|
|
||||||
|
|
@ -2517,6 +2517,15 @@ public sealed class WorldSession : IDisposable
|
||||||
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
|
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Query the local player's house info — either owned house
|
||||||
|
/// data (0x0225) or a no-house status (0x0226) comes back
|
||||||
|
/// (0x021E).</summary>
|
||||||
|
public void SendHouseQuery()
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(ClientCommandRequests.BuildHouseQuery(seq));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Query the local character's played time (0x01C2).</summary>
|
/// <summary>Query the local character's played time (0x01C2).</summary>
|
||||||
public void SendQueryAge()
|
public void SendQueryAge()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1266,6 +1266,14 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
// RuntimeLiveEntitySessionController's onLoginCompleteSent
|
// RuntimeLiveEntitySessionController's onLoginCompleteSent
|
||||||
// doc for the other two.
|
// doc for the other two.
|
||||||
_optionsSeeder?.NoteLoginCompleteSent();
|
_optionsSeeder?.NoteLoginCompleteSent();
|
||||||
|
// Night-round review F2: CM_House::Event_QueryHouse
|
||||||
|
// @0x006aaa00 tail-calls unconditionally from the end of
|
||||||
|
// CPlayerSystem::InitializePlayer — the same once-per-session
|
||||||
|
// function AttemptSendLoginCompleteNotification lives in
|
||||||
|
// (guarded by player_initialized) — right after that
|
||||||
|
// notification. This is the headless direct (non-portal)
|
||||||
|
// first-entry completion edge.
|
||||||
|
session.SendHouseQuery();
|
||||||
},
|
},
|
||||||
_acceptedPositionDrive);
|
_acceptedPositionDrive);
|
||||||
_eventRoute = eventRoute;
|
_eventRoute = eventRoute;
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ internal enum GameRuntimeConstructionPoint
|
||||||
FellowshipCreated,
|
FellowshipCreated,
|
||||||
AllegianceCreated,
|
AllegianceCreated,
|
||||||
TradeCreated,
|
TradeCreated,
|
||||||
|
HouseCreated,
|
||||||
MovementCreated,
|
MovementCreated,
|
||||||
ActionsCreated,
|
ActionsCreated,
|
||||||
EnvironmentCreated,
|
EnvironmentCreated,
|
||||||
|
|
@ -127,6 +128,7 @@ internal sealed class GameRuntimeConstructionContext
|
||||||
public RuntimeFellowshipState? Fellowship { get; set; }
|
public RuntimeFellowshipState? Fellowship { get; set; }
|
||||||
public RuntimeAllegianceState? Allegiance { get; set; }
|
public RuntimeAllegianceState? Allegiance { get; set; }
|
||||||
public RuntimeTradeState? Trade { get; set; }
|
public RuntimeTradeState? Trade { get; set; }
|
||||||
|
public RuntimeHouseState? House { get; set; }
|
||||||
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
||||||
public RuntimeActionState? Actions { get; set; }
|
public RuntimeActionState? Actions { get; set; }
|
||||||
public GameRuntimeEventHub? Events { get; set; }
|
public GameRuntimeEventHub? Events { get; set; }
|
||||||
|
|
@ -282,6 +284,17 @@ public sealed class GameRuntime
|
||||||
context,
|
context,
|
||||||
faultInjection);
|
faultInjection);
|
||||||
|
|
||||||
|
// House tab (Batch C, Map/House toolbar panel, 2026-08-17):
|
||||||
|
// deliberately minimal owner (ISSUES #413's own sizing note) —
|
||||||
|
// no live-object side effects, nothing to dispose, so no
|
||||||
|
// construction.Own() (unlike Trade above, which owns staged
|
||||||
|
// items' TradeState flags on live objects).
|
||||||
|
context.House = new RuntimeHouseState(context.EntityObjects.Objects);
|
||||||
|
Fault(
|
||||||
|
GameRuntimeConstructionPoint.HouseCreated,
|
||||||
|
context,
|
||||||
|
faultInjection);
|
||||||
|
|
||||||
context.Movement = new RuntimeLocalPlayerMovementState();
|
context.Movement = new RuntimeLocalPlayerMovementState();
|
||||||
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
||||||
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
||||||
|
|
@ -337,7 +350,8 @@ public sealed class GameRuntime
|
||||||
context.PlayerIdentity,
|
context.PlayerIdentity,
|
||||||
context.Fellowship,
|
context.Fellowship,
|
||||||
context.Allegiance,
|
context.Allegiance,
|
||||||
context.Trade);
|
context.Trade,
|
||||||
|
context.House);
|
||||||
|
|
||||||
context.Movement.AttachPhysicsPublication(
|
context.Movement.AttachPhysicsPublication(
|
||||||
new RuntimeLocalPlayerPhysicsPublicationState(
|
new RuntimeLocalPlayerPhysicsPublicationState(
|
||||||
|
|
@ -393,6 +407,7 @@ public sealed class GameRuntime
|
||||||
FellowshipOwner = context.Fellowship;
|
FellowshipOwner = context.Fellowship;
|
||||||
AllegianceOwner = context.Allegiance;
|
AllegianceOwner = context.Allegiance;
|
||||||
TradeOwner = context.Trade;
|
TradeOwner = context.Trade;
|
||||||
|
HouseOwner = context.House;
|
||||||
MovementOwner = context.Movement;
|
MovementOwner = context.Movement;
|
||||||
ActionOwner = context.Actions;
|
ActionOwner = context.Actions;
|
||||||
EnvironmentOwner = environment;
|
EnvironmentOwner = environment;
|
||||||
|
|
@ -501,6 +516,11 @@ public sealed class GameRuntime
|
||||||
|
|
||||||
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
||||||
public RuntimeTradeState TradeOwner { get; }
|
public RuntimeTradeState TradeOwner { get; }
|
||||||
|
|
||||||
|
/// <summary>Batch C (2026-08-17): House tab minimal owner — see
|
||||||
|
/// <see cref="RuntimeHouseState"/>'s own class doc for the sizing
|
||||||
|
/// rationale.</summary>
|
||||||
|
public RuntimeHouseState HouseOwner { get; }
|
||||||
public RuntimeActionState ActionOwner { get; }
|
public RuntimeActionState ActionOwner { get; }
|
||||||
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
||||||
internal RuntimeLocalPlayerPhysicsPublicationState
|
internal RuntimeLocalPlayerPhysicsPublicationState
|
||||||
|
|
|
||||||
217
src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs
Normal file
217
src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Properties;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical presentation-independent owner for the House tab of retail's
|
||||||
|
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately MINIMAL —
|
||||||
|
/// "houseless-status only" per ISSUES #413's own sizing note: a full
|
||||||
|
/// <c>RuntimeTradeState</c>-weight owner (construction-transaction
|
||||||
|
/// <c>Fault()</c> injection point, disposal ordering, convergence tracking)
|
||||||
|
/// is disproportionate for what this slice needs, since (unlike Trade) this
|
||||||
|
/// owner holds no live-object side effects and nothing that requires
|
||||||
|
/// disposal.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Retail behavior, exhaustively verified against the decomp before
|
||||||
|
/// writing this class (all seven line builders read, not just the one
|
||||||
|
/// ported here):</b> <c>gmHouseUI::PostInit @0x004a2710</c> never calls
|
||||||
|
/// <c>Update</c>/<c>DisplayHouseData</c> — the House ListBox
|
||||||
|
/// (<c>0x100001e6</c>) starts genuinely empty (live-DAT-confirmed,
|
||||||
|
/// <c>MapHousePanelSlotProbeTests</c>: <c>children=0</c>, and the whole
|
||||||
|
/// House page <c>0x100001F7</c> has NO other static content besides that
|
||||||
|
/// one empty ListBox). Content appears only after a server notice
|
||||||
|
/// (0x0225-0x0228) arrives and <c>Update</c>/<c>DisplayHouseData</c> runs
|
||||||
|
/// the seven <c>Display*</c> builders in order. SIX of them
|
||||||
|
/// (<c>DisplayBuyPayment</c>, <c>DisplayRentPayment</c>,
|
||||||
|
/// <c>DisplayBuyTime</c>, <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
|
||||||
|
/// <c>DisplayWarningText</c>) open with <c>if (this->m_pHouseData != 0)</c>
|
||||||
|
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
|
||||||
|
/// item 3.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The SEVENTH, <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>, does
|
||||||
|
/// NOT gate on <c>m_pHouseData</c> — it always runs, reading the LOCAL
|
||||||
|
/// PLAYER's own <c>PropertyInt.HousePurchaseTimestamp</c> (0xC7 = 199
|
||||||
|
/// decimal) via <c>CBaseQualities::InqInt</c> and
|
||||||
|
/// <c>HouseSystem::HasPurchaseWaitPeriodExpired</c>
|
||||||
|
/// (<c>@0x005bb1d0</c>: <c>(Timer::get_real_time() - timestamp) >
|
||||||
|
/// 0x278d00</c>; <c>Timer::get_real_time = time(0)</c>, Unix epoch
|
||||||
|
/// seconds; <c>0x278d00</c> = 2,592,000 s = 30 days). For a fresh/houseless
|
||||||
|
/// character with no timestamp ever set (absent property reads as 0), this
|
||||||
|
/// is trivially true, taking the "expired" branch, which reads
|
||||||
|
/// <c>m_pHouseData == 0</c> (still houseless) and emits the ONE literal
|
||||||
|
/// string at <c>data_7ab7f0</c>: <b>"You may buy another house
|
||||||
|
/// immediately."</b> That is the exact, decomp-verified, single line of
|
||||||
|
/// content a houseless character's House tab shows once queried — this
|
||||||
|
/// class ports exactly that (and its owns-a-house sibling at
|
||||||
|
/// <c>data_7ab818</c>, unreachable by a fresh character but faithfully
|
||||||
|
/// ported alongside it). No other function, WeenieError-to-chat mapping,
|
||||||
|
/// or authored LayoutDesc content anywhere in the decomp/live DAT produces
|
||||||
|
/// a second line for the houseless case — a broader search for chat-scroll
|
||||||
|
/// strings mentioning house ownership found only unrelated, differently
|
||||||
|
/// worded command-error text (<c>"You do not own a house!"</c>,
|
||||||
|
/// WeenieError <c>0x45E</c>/<c>0x45F</c>, and <c>"You must own a house to
|
||||||
|
/// use this command."</c>, WeenieError <c>0x47F</c>) routed through the
|
||||||
|
/// GENERIC WeenieError chat dispatcher, never through <c>gmHouseUI</c>'s
|
||||||
|
/// own notice handlers (which discard the wire WeenieError entirely — see
|
||||||
|
/// <see cref="ApplyHouseStatus"/>).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The NOT-yet-expired branch of <c>DisplayPurchaseTimeText</c> is now
|
||||||
|
/// ported (night-round review F8) — its literals were NOT unrecoverable;
|
||||||
|
/// the "BN-truncated suffix" was another instance of Binary Ninja's
|
||||||
|
/// operator-overload plumbing obscuring plain pushed string constants (the
|
||||||
|
/// same artifact class TS-85/F3 hit). A direct capstone disassembly of the
|
||||||
|
/// raw bytes resolves all three pieces retail concatenates: prefix
|
||||||
|
/// <c>"You may buy another landscape house at "</c> @<c>0x7ab790</c>
|
||||||
|
/// (pushed <c>@0x004a3265</c>), the <c>strftime</c> format literal
|
||||||
|
/// <c>"%c"</c> @<c>0x7ab7ec</c> (pushed <c>@0x004a321d</c>) applied to
|
||||||
|
/// <c>localtime(timestamp + 0x278d00)</c> — the expiry moment, not "now" —
|
||||||
|
/// and suffix <c>". This restriction does not apply to apartments."</c>
|
||||||
|
/// @<c>0x7ab7b8</c> (pushed <c>@0x004a3235</c>). <c>strftime</c>'s
|
||||||
|
/// <c>"%c"</c> is the C runtime's locale-default full date+time
|
||||||
|
/// representation; this port's honest analogue is .NET's own
|
||||||
|
/// culture-default <c>DateTime.ToString()</c> (no explicit format) — NOT a
|
||||||
|
/// byte-identical reproduction of the CRT's locale table, since .NET and
|
||||||
|
/// the CRT do not share a formatting engine, but the same "whatever the
|
||||||
|
/// process's own locale says" intent. Register row IA-23
|
||||||
|
/// (<c>docs/architecture/retail-divergence-register.md</c>) records this
|
||||||
|
/// approximation.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RuntimeHouseState
|
||||||
|
{
|
||||||
|
/// <summary><c>HouseSystem::HasPurchaseWaitPeriodExpired</c>'s
|
||||||
|
/// literal, <c>0x278d00</c> = 2,592,000 seconds = 30 days.</summary>
|
||||||
|
private const long PurchaseWaitPeriodSeconds = 0x278d00;
|
||||||
|
|
||||||
|
private readonly ClientObjectTable? _objects;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private bool _hasReceivedNotice;
|
||||||
|
private bool _ownsHouse;
|
||||||
|
private IReadOnlyList<string> _lines = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>Borrows the canonical object table (optional for bare
|
||||||
|
/// fixtures) to read the local player's own
|
||||||
|
/// <c>PropertyInt.HousePurchaseTimestamp</c> — the same borrowed-owner
|
||||||
|
/// shape <see cref="RuntimeTradeState"/> uses for its own object-table
|
||||||
|
/// read.</summary>
|
||||||
|
public RuntimeHouseState(
|
||||||
|
ClientObjectTable? objects = null, TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_objects = objects;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The House tab's exact ListBox content — empty until the
|
||||||
|
/// first server notice arrives, matching retail's own PostInit (never
|
||||||
|
/// calls Update/DisplayHouseData).</summary>
|
||||||
|
public IReadOnlyList<string> Lines
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _lines; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether any of the four House notices (0x0225-0x0228) has
|
||||||
|
/// arrived this session.</summary>
|
||||||
|
public bool HasReceivedNotice
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _hasReceivedNotice; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
|
||||||
|
/// (owned-house case). Only <see cref="_ownsHouse"/> is consumed today;
|
||||||
|
/// the owned-house payload itself (buy/rent payments, times, location)
|
||||||
|
/// feeds ISSUES #413's remaining six builders, not yet ported.</summary>
|
||||||
|
public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_hasReceivedNotice = true;
|
||||||
|
_ownsHouse = true;
|
||||||
|
Recompute(selfGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0x0226 HouseStatus — <c>RecvNotice_FailedHouseTransaction</c>
|
||||||
|
/// (the "no house owned" reply to a HouseQuery, per ACE's
|
||||||
|
/// <c>HandleActionQueryHouse</c>). <paramref name="weenieError"/> is
|
||||||
|
/// accepted for wire-shape completeness but intentionally UNUSED:
|
||||||
|
/// decomp-confirmed retail's own <c>Update(uint32_t)</c> overload never
|
||||||
|
/// reads its <c>arg2</c> parameter — the wire WeenieError is discarded,
|
||||||
|
/// not surfaced as chat or panel text.</summary>
|
||||||
|
public void ApplyHouseStatus(uint weenieError, uint selfGuid)
|
||||||
|
{
|
||||||
|
_ = weenieError;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_hasReceivedNotice = true;
|
||||||
|
_ownsHouse = false;
|
||||||
|
Recompute(selfGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Generation reset — a fresh login must not show a previous
|
||||||
|
/// character's house-query result. Restores the exact pre-notice
|
||||||
|
/// "genuinely empty" state.</summary>
|
||||||
|
public void ResetSession()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_hasReceivedNotice = false;
|
||||||
|
_ownsHouse = false;
|
||||||
|
_lines = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>,
|
||||||
|
/// both branches now ported. Must hold <see cref="_gate"/>.</summary>
|
||||||
|
private void Recompute(uint selfGuid)
|
||||||
|
{
|
||||||
|
int timestamp = _objects?.Get(selfGuid)?.Properties
|
||||||
|
.GetInt((uint)PropertyInt.HousePurchaseTimestamp) ?? 0;
|
||||||
|
long nowEpochSeconds = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
|
||||||
|
bool expired = (nowEpochSeconds - timestamp) > PurchaseWaitPeriodSeconds;
|
||||||
|
|
||||||
|
if (!expired)
|
||||||
|
{
|
||||||
|
// Not-yet-expired branch, byte-decoded (night-round review
|
||||||
|
// F8): retail computes the EXPIRY moment (timestamp + 30 days,
|
||||||
|
// @0x004a3212's `var_42c += 0x278d00`), formats it through
|
||||||
|
// `localtime` + `strftime("%c", ...)` (@0x004a322c), and
|
||||||
|
// concatenates prefix + date + suffix
|
||||||
|
// (@0x004a3265/@0x004a321d/@0x004a3235). .NET's
|
||||||
|
// culture-default DateTime.ToString() is the honest %c
|
||||||
|
// analogue (see the class doc's own note — not byte-identical
|
||||||
|
// to the CRT's locale table, same "process locale" intent).
|
||||||
|
// TimeProvider.LocalTimeZone (not the ambient system zone
|
||||||
|
// directly) keeps this deterministically testable while
|
||||||
|
// matching retail's own `localtime()` (process-local time) in
|
||||||
|
// production, where TimeProvider.System.LocalTimeZone IS
|
||||||
|
// TimeZoneInfo.Local.
|
||||||
|
DateTimeOffset expiryUtc = DateTimeOffset.FromUnixTimeSeconds(
|
||||||
|
timestamp + PurchaseWaitPeriodSeconds);
|
||||||
|
DateTime expiryLocal = TimeZoneInfo.ConvertTime(
|
||||||
|
expiryUtc, _timeProvider.LocalTimeZone).DateTime;
|
||||||
|
_lines = new[]
|
||||||
|
{
|
||||||
|
"You may buy another landscape house at "
|
||||||
|
+ expiryLocal.ToString(CultureInfo.CurrentCulture)
|
||||||
|
+ ". This restriction does not apply to apartments.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lines = new[]
|
||||||
|
{
|
||||||
|
_ownsHouse
|
||||||
|
? "You may buy another house immediately after you abandon this one."
|
||||||
|
: "You may buy another house immediately.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,18 +50,34 @@ public sealed class RuntimeSpellCastState
|
||||||
public uint? LastRequestedTargetId { get; private set; }
|
public uint? LastRequestedTargetId { get; private set; }
|
||||||
public event Action? StateChanged;
|
public event Action? StateChanged;
|
||||||
|
|
||||||
public bool IsTargetReady(uint spellId)
|
public bool IsTargetReady(uint spellId) =>
|
||||||
|
EvaluateCastGate(spellId)
|
||||||
|
is SpellCastGate.NoTargetNeeded or SpellCastGate.TargetCompatible;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30</c>'s
|
||||||
|
/// plain-spell branch (<c>m_endowmentItemID == 0</c>) gate, split out
|
||||||
|
/// (night-round review F3) so the tooltip presenter can distinguish
|
||||||
|
/// retail's four states rather than just the enabled/disabled boolean
|
||||||
|
/// <see cref="IsTargetReady"/> collapses them to. Byte-decoded call
|
||||||
|
/// sites: the untargeted/self-cast branch at <c>0x4c6f35</c>, the
|
||||||
|
/// targeted-and-compatible branch at <c>0x4c6e57</c>
|
||||||
|
/// (<c>ClientMagicSystem::ObjectCompatibleWithSpell @0x567c30</c>), and
|
||||||
|
/// the two disabled tails at <c>0x4c6f04</c> (incompatible) and
|
||||||
|
/// <c>0x4c6e2b</c> (nothing selected).
|
||||||
|
/// </summary>
|
||||||
|
public SpellCastGate EvaluateCastGate(uint spellId)
|
||||||
{
|
{
|
||||||
if (!_spellbook.Knows(spellId)
|
if (!_spellbook.Knows(spellId)
|
||||||
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
|
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
|
||||||
return false;
|
return SpellCastGate.Unknown;
|
||||||
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
|
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
|
||||||
return true;
|
return SpellCastGate.NoTargetNeeded;
|
||||||
return _selection.SelectedObjectId is uint target and not 0u
|
if (_selection.SelectedObjectId is not (uint target and not 0u))
|
||||||
&& _operations.IsTargetCompatible(
|
return SpellCastGate.NoTargetSelected;
|
||||||
target,
|
return _operations.IsTargetCompatible(target, spell, showMessage: false)
|
||||||
spell,
|
? SpellCastGate.TargetCompatible
|
||||||
showMessage: false);
|
: SpellCastGate.TargetIncompatible;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CastRequestResult Cast(uint spellId)
|
public CastRequestResult Cast(uint spellId)
|
||||||
|
|
@ -159,3 +175,27 @@ public enum CastRequestResult
|
||||||
MissingComponents,
|
MissingComponents,
|
||||||
Unavailable,
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The four retail cast-button states <c>gmSpellcastingUI::
|
||||||
|
/// UpdateCastButtonTooltip @0x004c6a30</c>'s plain-spell branch
|
||||||
|
/// distinguishes — see <see cref="RuntimeSpellCastState.EvaluateCastGate"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum SpellCastGate
|
||||||
|
{
|
||||||
|
/// <summary>Spell metadata is missing / not known.</summary>
|
||||||
|
Unknown,
|
||||||
|
/// <summary>Untargeted, self-targeted, or no target mask — always
|
||||||
|
/// castable. Retail: <c>"CAST %hs"</c> @0x7b63a4.</summary>
|
||||||
|
NoTargetNeeded,
|
||||||
|
/// <summary>A target is selected and compatible. Retail: <c>"CAST
|
||||||
|
/// %hs"</c> @0x7b63a4, then <c>" on %s"</c> @0x7b6464 appended with
|
||||||
|
/// the target's name.</summary>
|
||||||
|
TargetCompatible,
|
||||||
|
/// <summary>A target is selected but incompatible. Retail: <c>"You
|
||||||
|
/// must select an appropriate target for %hs"</c> @0x7b6348.</summary>
|
||||||
|
TargetIncompatible,
|
||||||
|
/// <summary>No target is selected. Retail: <c>"You must select a
|
||||||
|
/// target for %hs"</c> @0x7b63b8.</summary>
|
||||||
|
NoTargetSelected,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,24 @@ public enum RuntimeGenerationResetStage
|
||||||
/// beside its fellowship/allegiance precedents.
|
/// beside its fellowship/allegiance precedents.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Trade = 14,
|
Trade = 14,
|
||||||
BeginEntityRetirement = 15,
|
/// <summary>
|
||||||
RetireEntities = 16,
|
/// Batch C (Map/House toolbar panel, 2026-08-17): the House tab's
|
||||||
DrainHostProjection = 17,
|
/// query result is session-scoped like fellowship/allegiance/trade
|
||||||
CompleteCanonicalEntities = 18,
|
/// above — a fresh login must not show a previous character's house
|
||||||
CompleteHostProjection = 19,
|
/// data. See <see cref="RuntimeHouseState"/>'s class doc for why this
|
||||||
ChatIdentity = 20,
|
/// owner is lighter-weight than its three siblings (no disposal, no
|
||||||
PlayerSnapshots = 21,
|
/// construction-transaction Fault() point).
|
||||||
PlayerIdentity = 22,
|
/// </summary>
|
||||||
Complete = 23,
|
House = 15,
|
||||||
|
BeginEntityRetirement = 16,
|
||||||
|
RetireEntities = 17,
|
||||||
|
DrainHostProjection = 18,
|
||||||
|
CompleteCanonicalEntities = 19,
|
||||||
|
CompleteHostProjection = 20,
|
||||||
|
ChatIdentity = 21,
|
||||||
|
PlayerSnapshots = 22,
|
||||||
|
PlayerIdentity = 23,
|
||||||
|
Complete = 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct RuntimeGenerationResetSnapshot(
|
public readonly record struct RuntimeGenerationResetSnapshot(
|
||||||
|
|
@ -119,6 +128,7 @@ public sealed class RuntimeGenerationReset
|
||||||
private readonly RuntimeFellowshipState _fellowship;
|
private readonly RuntimeFellowshipState _fellowship;
|
||||||
private readonly RuntimeAllegianceState _allegiance;
|
private readonly RuntimeAllegianceState _allegiance;
|
||||||
private readonly RuntimeTradeState _trade;
|
private readonly RuntimeTradeState _trade;
|
||||||
|
private readonly RuntimeHouseState _house;
|
||||||
private ResetState? _state;
|
private ResetState? _state;
|
||||||
private RuntimeGenerationToken _lastCompletedGeneration;
|
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||||
private bool _hasCompletedGeneration;
|
private bool _hasCompletedGeneration;
|
||||||
|
|
@ -136,7 +146,8 @@ public sealed class RuntimeGenerationReset
|
||||||
RuntimeLocalPlayerIdentityState identity,
|
RuntimeLocalPlayerIdentityState identity,
|
||||||
RuntimeFellowshipState fellowship,
|
RuntimeFellowshipState fellowship,
|
||||||
RuntimeAllegianceState allegiance,
|
RuntimeAllegianceState allegiance,
|
||||||
RuntimeTradeState trade)
|
RuntimeTradeState trade,
|
||||||
|
RuntimeHouseState house)
|
||||||
{
|
{
|
||||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||||
_communication = communication
|
_communication = communication
|
||||||
|
|
@ -157,6 +168,7 @@ public sealed class RuntimeGenerationReset
|
||||||
_allegiance = allegiance
|
_allegiance = allegiance
|
||||||
?? throw new ArgumentNullException(nameof(allegiance));
|
?? throw new ArgumentNullException(nameof(allegiance));
|
||||||
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
|
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
|
||||||
|
_house = house ?? throw new ArgumentNullException(nameof(house));
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||||
|
|
@ -333,6 +345,9 @@ public sealed class RuntimeGenerationReset
|
||||||
case RuntimeGenerationResetStage.Trade:
|
case RuntimeGenerationResetStage.Trade:
|
||||||
Advance(state, _trade.Clear);
|
Advance(state, _trade.Clear);
|
||||||
break;
|
break;
|
||||||
|
case RuntimeGenerationResetStage.House:
|
||||||
|
Advance(state, _house.ResetSession);
|
||||||
|
break;
|
||||||
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||||
_ = _entityObjects.BeginSessionClear();
|
_ = _entityObjects.BeginSessionClear();
|
||||||
state.Retirements = _entityObjects
|
state.Retirements = _entityObjects
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,11 @@ public sealed record LiveSocialSessionBindings(
|
||||||
RuntimeAllegianceState? Allegiance = null,
|
RuntimeAllegianceState? Allegiance = null,
|
||||||
// Secure trade (2026-08-14): the third sibling J-owner, same
|
// Secure trade (2026-08-14): the third sibling J-owner, same
|
||||||
// trailing/optional compatibility convention.
|
// trailing/optional compatibility convention.
|
||||||
RuntimeTradeState? Trade = null);
|
RuntimeTradeState? Trade = null,
|
||||||
|
// Batch C (Map/House toolbar panel, 2026-08-17): same trailing/optional
|
||||||
|
// compatibility convention — a minimal owner (RuntimeHouseState's own
|
||||||
|
// class doc), not a full sibling J-owner.
|
||||||
|
RuntimeHouseState? House = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Owns every inbound subscription for one exact live session. Domain state
|
/// Owns every inbound subscription for one exact live session. Domain state
|
||||||
|
|
@ -322,6 +326,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
||||||
: null,
|
: null,
|
||||||
onTradeClearAcceptance: social.Trade is { } tradeClear
|
onTradeClearAcceptance: social.Trade is { } tradeClear
|
||||||
? tradeClear.ApplyClearAcceptance
|
? tradeClear.ApplyClearAcceptance
|
||||||
|
: null,
|
||||||
|
// Batch C (Map/House toolbar panel, 2026-08-17): same
|
||||||
|
// conditional delegate-hole discipline as trade above.
|
||||||
|
onHouseData: social.House is { } houseData
|
||||||
|
? data => houseData.ApplyHouseData(data, inventory.PlayerGuid())
|
||||||
|
: null,
|
||||||
|
onHouseStatus: social.House is { } houseStatus
|
||||||
|
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
|
||||||
: null));
|
: null));
|
||||||
ConstructionCheckpoint();
|
ConstructionCheckpoint();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,14 @@ public sealed class RuntimeLiveEntitySessionController
|
||||||
// truthful terminal admission edge.
|
// truthful terminal admission edge.
|
||||||
_initialLoginCompleteSent = true;
|
_initialLoginCompleteSent = true;
|
||||||
_session.SendGameAction(GameActionLoginComplete.Build());
|
_session.SendGameAction(GameActionLoginComplete.Build());
|
||||||
|
// Night-round review F2: CM_House::Event_QueryHouse
|
||||||
|
// @0x006aaa00 tail-calls unconditionally from the end of
|
||||||
|
// CPlayerSystem::InitializePlayer — the same once-per-session
|
||||||
|
// function AttemptSendLoginCompleteNotification lives in
|
||||||
|
// (guarded by player_initialized) — right after that
|
||||||
|
// notification. This is the content-less direct host's own
|
||||||
|
// terminal admission edge, its equivalent moment.
|
||||||
|
_session.SendHouseQuery();
|
||||||
_onLoginCompleteSent?.Invoke();
|
_onLoginCompleteSent?.Invoke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,8 @@ public sealed class InteractionRetainedUiCompositionTests
|
||||||
ClientTime: static () => 0d,
|
ClientTime: static () => 0d,
|
||||||
Log: static _ => { },
|
Log: static _ => { },
|
||||||
GpuDevice: null!,
|
GpuDevice: null!,
|
||||||
GpuFrameSource: null!);
|
GpuFrameSource: null!,
|
||||||
|
CurrentCalendar: static () => default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public InteractionRetainedUiDependencies Dependencies { get; }
|
public InteractionRetainedUiDependencies Dependencies { get; }
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,46 @@ public sealed class RetailSkillFormulaTests
|
||||||
Assert.Equal(uint.MaxValue, result);
|
Assert.Equal(uint.MaxValue, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F13 (night-round review): <see cref="RetailSkillFormula.FormatFormula"/>
|
||||||
|
/// previously read the raw signed <c>int</c> fields directly instead of
|
||||||
|
/// the SAME unsigned reinterpretation <see cref="RetailSkillFormula.TryCalculate"/>
|
||||||
|
/// uses. A multiplier whose stored bit pattern has the high bit set
|
||||||
|
/// would read as a small negative number here (failing the
|
||||||
|
/// <c>>= 1</c> hasAttr gate, or printing a negative multiplier) instead
|
||||||
|
/// of the huge unsigned value <c>TryCalculate</c> actually computes
|
||||||
|
/// with for the SAME formula.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FormatFormula_MultiplierIsReinterpretedAsRetailUnsignedWord()
|
||||||
|
{
|
||||||
|
SkillFormula formula = Formula(w: 0, x: -1, y: 0, z: 1);
|
||||||
|
formula.Attribute1 = AttributeId.Strength;
|
||||||
|
|
||||||
|
string? text = RetailSkillFormula.FormatFormula(formula);
|
||||||
|
|
||||||
|
Assert.NotNull(text);
|
||||||
|
Assert.Contains(uint.MaxValue.ToString(), text);
|
||||||
|
Assert.DoesNotContain("-1", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Same reinterpretation, but for the divisor and additive-bonus
|
||||||
|
/// suffixes rather than the multiplier — both must read unsigned too.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void FormatFormula_DivisorAndAdditiveBonusAreReinterpretedAsRetailUnsignedWords()
|
||||||
|
{
|
||||||
|
SkillFormula formula = Formula(w: -1, x: 1, y: 0, z: unchecked((uint)-2));
|
||||||
|
formula.Attribute1 = AttributeId.Strength;
|
||||||
|
|
||||||
|
string? text = RetailSkillFormula.FormatFormula(formula);
|
||||||
|
|
||||||
|
Assert.NotNull(text);
|
||||||
|
Assert.Contains($"/ {unchecked((uint)-2)}", text);
|
||||||
|
Assert.Contains($"+{uint.MaxValue}", text);
|
||||||
|
Assert.DoesNotContain("-1", text);
|
||||||
|
Assert.DoesNotContain("-2", text);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
|
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1108,4 +1108,119 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
+ $"built widget rendered {actual.Value}.");
|
+ $"built widget rendered {actual.Value}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── #412-class regression: Config tab content escaping the window frame ──
|
||||||
|
//
|
||||||
|
// 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's
|
||||||
|
// screenshot showed the Config tab's Apply/Reset/Defaults footer sitting
|
||||||
|
// mid-panel with further rows (Full Screen, Sync With Refresh Rate,
|
||||||
|
// Screen Brightness, Adaptive Degrade, the quality dropdowns...) drawing
|
||||||
|
// BELOW the window's bottom edge, outside the panel frame. Live-DAT
|
||||||
|
// measured root cause: the tab host's authored page slot (0x10000213,
|
||||||
|
// 298x575) is taller than its actual mounted container (the merged
|
||||||
|
// 0x1000018D root, authored 300x362 — retail's own
|
||||||
|
// UIElement::UpdateForParentSizeChange @0x00462640 four-edge policy
|
||||||
|
// shrinks it correctly on the first ApplyAnchor pass). The Config ListBox
|
||||||
|
// (0x10000200, 276x560) shrinks right behind it via the SAME per-element
|
||||||
|
// UiLayoutPolicy. But UiTemplateListBox.Viewport (the UiScrollablePanel
|
||||||
|
// that actually hosts + clips every row) is a programmatic C# element
|
||||||
|
// seeded at Bind time — BEFORE any ApplyAnchor pass has ever run — with
|
||||||
|
// the ListBox's THEN-current (pre-shrink) 276x560 size. Its own legacy
|
||||||
|
// Left|Top|Right|Bottom anchor baseline is captured lazily, on its first
|
||||||
|
// ApplyAnchor call, which lands AFTER the ListBox has already shrunk in
|
||||||
|
// that same frame — producing a negative captured bottom margin that
|
||||||
|
// ComputeAnchoredRect's stretch math preserves forever: the viewport
|
||||||
|
// stayed locked at its original 560px height, well past the real ~297px
|
||||||
|
// available, so rows drew (and were culled) against a bound retail never
|
||||||
|
// actually gave the window on screen. Fixed in UiTemplateListBox.Viewport
|
||||||
|
// by forcing the capture immediately after seeding, while the viewport's
|
||||||
|
// own Width/Height still exactly equal a zero-margin baseline.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass()
|
||||||
|
{
|
||||||
|
// Pins the LIVE-DAT-measured authored facts this whole bug turns on:
|
||||||
|
// the tab host's merged root is the SLOT's own (cropped) 300x362
|
||||||
|
// extent, but the Config page slot underneath keeps ITS OWN larger
|
||||||
|
// authored design geometry (298x575, drawn against a 300x600 design
|
||||||
|
// canvas) until a layout pass actually reflows it.
|
||||||
|
(OptionsPanelController controller, _, bool bound) = BindReal();
|
||||||
|
Assert.True(bound);
|
||||||
|
|
||||||
|
Assert.Equal(300f, controller.TabPanel.Width);
|
||||||
|
Assert.Equal(362f, controller.TabPanel.Height);
|
||||||
|
|
||||||
|
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||||
|
Assert.Equal(575f, configSlot.Height);
|
||||||
|
Assert.NotNull(configSlot.LayoutPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass()
|
||||||
|
{
|
||||||
|
(OptionsPanelController controller, _, bool bound) = BindReal();
|
||||||
|
Assert.True(bound);
|
||||||
|
|
||||||
|
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||||
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||||
|
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
||||||
|
UiElement viewport = Assert.Single(listBox.Children);
|
||||||
|
|
||||||
|
// Drive the SAME top-down ApplyAnchor walk DrawSelfAndChildren runs
|
||||||
|
// every real frame — parent before children, all the way down —
|
||||||
|
// TWICE, to prove the result is a stable steady state and not an
|
||||||
|
// artifact of a single simulated pass.
|
||||||
|
for (int frame = 0; frame < 2; frame++)
|
||||||
|
ApplyAnchorRecursive(controller.TabPanel);
|
||||||
|
|
||||||
|
// The viewport must track the REAL (shrunk) ListBox extent, not stay
|
||||||
|
// locked at its original oversized 560px design height.
|
||||||
|
Assert.True(
|
||||||
|
viewport.Height <= listBox.Height + 0.5f,
|
||||||
|
$"viewport height {viewport.Height} exceeds its ListBox's actual "
|
||||||
|
+ $"height {listBox.Height} — rows will draw/cull past where "
|
||||||
|
+ "the window actually is (the #412-class bug).");
|
||||||
|
|
||||||
|
// Nothing in the Config page may extend past the slot's own bottom
|
||||||
|
// edge, and the slot itself may not extend past the mounted window's
|
||||||
|
// own bottom edge — the exact "content escapes the window frame"
|
||||||
|
// symptom the user's screenshot showed.
|
||||||
|
float slotBottom = configSlot.Top + configSlot.Height;
|
||||||
|
Assert.True(
|
||||||
|
slotBottom <= controller.TabPanel.Height + 0.5f,
|
||||||
|
$"Config slot bottom {slotBottom} exceeds the mounted window's "
|
||||||
|
+ $"own height {controller.TabPanel.Height}.");
|
||||||
|
|
||||||
|
foreach (uint footerId in new[]
|
||||||
|
{
|
||||||
|
ConfigOptionsPageController_ApplyButtonId,
|
||||||
|
ConfigOptionsPageController_ResetButtonId,
|
||||||
|
ConfigOptionsPageController_DefaultsButtonId,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
UiElement? btn = UiElement.FindDescendant(configSlot, footerId);
|
||||||
|
Assert.NotNull(btn);
|
||||||
|
float bottom = btn!.Top + btn.Height;
|
||||||
|
Assert.True(
|
||||||
|
bottom <= slotBottom + 0.5f,
|
||||||
|
$"footer 0x{footerId:X8} bottom {bottom} exceeds the Config "
|
||||||
|
+ $"slot's own bottom {slotBottom}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply/Reset/Defaults element ids — ConfigOptionsPageController's own
|
||||||
|
// constants of the same name are private; mirrored here rather than
|
||||||
|
// widening that class's surface just for this test.
|
||||||
|
private const uint ConfigOptionsPageController_ApplyButtonId = 0x100001FCu;
|
||||||
|
private const uint ConfigOptionsPageController_ResetButtonId = 0x100001FDu;
|
||||||
|
private const uint ConfigOptionsPageController_DefaultsButtonId = 0x100001FEu;
|
||||||
|
|
||||||
|
private static void ApplyAnchorRecursive(UiElement e)
|
||||||
|
{
|
||||||
|
foreach (UiElement child in e.Children)
|
||||||
|
{
|
||||||
|
child.ApplyAnchor(e.Width, e.Height);
|
||||||
|
ApplyAnchorRecursive(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,15 @@ public static class FixtureLoader
|
||||||
public static ElementInfo LoadSocialPanelHostInfos()
|
public static ElementInfo LoadSocialPanelHostInfos()
|
||||||
=> LoadInfos("social_panel_2100006E_1000018F.json");
|
=> LoadInfos("social_panel_2100006E_1000018F.json");
|
||||||
|
|
||||||
|
/// <summary>The two-tab Map/House panel host slot <c>0x1000018C</c> —
|
||||||
|
/// Batch C (overnight hover/UI round). This is what
|
||||||
|
/// <see cref="MapHousePanelController.Bind"/> actually mounts.</summary>
|
||||||
|
public static ImportedLayout LoadMapHouseHost()
|
||||||
|
=> LayoutImporter.Build(LoadMapHouseHostInfos(), _ => (0u, 0, 0), null);
|
||||||
|
|
||||||
|
public static ElementInfo LoadMapHouseHostInfos()
|
||||||
|
=> LoadInfos("map_house_2100006E_1000018C.json");
|
||||||
|
|
||||||
/// <summary>Configure Keyboard screen LayoutDesc <c>0x21000009</c> (standalone
|
/// <summary>Configure Keyboard screen LayoutDesc <c>0x21000009</c> (standalone
|
||||||
/// import — its own separate full-screen window, NOT nested under the Options
|
/// import — its own separate full-screen window, NOT nested under the Options
|
||||||
/// panel's <c>0x2100006E</c> host — Campaign OP slice OP8).</summary>
|
/// panel's <c>0x2100006E</c> host — Campaign OP slice OP8).</summary>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
using System.Linq;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller-level tests for <see cref="MapHousePanelController"/> against
|
||||||
|
/// the committed <c>map_house_2100006E_1000018C.json</c> fixture (Batch C,
|
||||||
|
/// overnight hover/UI round) — the SAME <c>LayoutImporter.ImportInfos(dats,
|
||||||
|
/// 0x2100006Eu, 0x1000018Cu)</c> catalog import
|
||||||
|
/// <see cref="RetailUiRuntime.MountMapHousePanel"/> performs against real
|
||||||
|
/// DATs. No DAT access, no live runtime — dat-free per the established
|
||||||
|
/// <see cref="FixtureLoader"/> pattern.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapHousePanelControllerTests
|
||||||
|
{
|
||||||
|
/// <summary>Serves the town-hotspot template. Returns a
|
||||||
|
/// <see cref="UiButton"/> — matching the live template's own authored
|
||||||
|
/// Type 1 (<c>MapHousePanelSlotProbeTests</c>: "hotspot template
|
||||||
|
/// type=1") — so <see cref="MapPageController.BuildTownMarkers"/>'s
|
||||||
|
/// <c>marker is UiButton</c> tooltip-text branch is actually exercised
|
||||||
|
/// by these tests. A real <see cref="RowTemplateResolver"/> would set
|
||||||
|
/// <c>DatElementId</c> the same way <see cref="LayoutImporter.Build"/>
|
||||||
|
/// does, so tests that need to find these markers back by id after the
|
||||||
|
/// fact need it too.</summary>
|
||||||
|
private static UiElement? FakeHotspotTemplate(uint layoutId, uint elementId)
|
||||||
|
=> new UiButton(new ElementInfo(), static _ => (0u, 0, 0))
|
||||||
|
{
|
||||||
|
Width = 10f,
|
||||||
|
Height = 10f,
|
||||||
|
DatElementId = elementId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The <see cref="MapPageController.Bindings.IconBuilder"/>
|
||||||
|
/// seam: builds m_pMap's two button-swallowed icon children from their
|
||||||
|
/// OWN <see cref="ElementInfo"/>s inside the panel-slot resolve tree
|
||||||
|
/// (register row AD-108 — a standalone re-import cannot find them on
|
||||||
|
/// the live DAT, so the icons are found under the already-resolved
|
||||||
|
/// <c>pageInfo</c> and built through this seam instead). Mirrors
|
||||||
|
/// production's <c>LayoutImporter.Build(info, ...).Root</c>, which sets
|
||||||
|
/// <c>DatElementId</c> from the info's own id.</summary>
|
||||||
|
private static UiElement? FakeIconBuilder(ElementInfo info)
|
||||||
|
=> new UiButton(new ElementInfo(), static _ => (0u, 0, 0))
|
||||||
|
{
|
||||||
|
Width = 10f,
|
||||||
|
Height = 10f,
|
||||||
|
DatElementId = info.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The House ListBox's own row template resolves to a
|
||||||
|
/// <see cref="UiText"/> in the live DAT (<c>MapHousePanelSlotProbeTests</c>:
|
||||||
|
/// "row template type=12" — <c>UIElement_Text</c>), unlike the Map tab's
|
||||||
|
/// UiButton hotspots above — a fresh instance per call, matching
|
||||||
|
/// production's real resolver.</summary>
|
||||||
|
private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId)
|
||||||
|
=> new UiText { Width = 280f, Height = 28f };
|
||||||
|
|
||||||
|
private static MapHousePanelController.Callbacks MakeCallbacks(
|
||||||
|
List<string>? calls = null,
|
||||||
|
Func<DerethDateTime.Calendar>? currentCalendar = null,
|
||||||
|
Func<uint>? playerCellId = null,
|
||||||
|
Func<CreateObject.ServerPosition?>? housePosition = null,
|
||||||
|
Func<IReadOnlyList<string>>? houseLines = null)
|
||||||
|
{
|
||||||
|
calls ??= new List<string>();
|
||||||
|
return new MapHousePanelController.Callbacks(
|
||||||
|
Toggle: () => calls.Add("toggle"),
|
||||||
|
Map: new MapPageController.Bindings(
|
||||||
|
CurrentCalendar: currentCalendar ?? (static () => default),
|
||||||
|
PlayerCellId: playerCellId ?? (static () => 0u),
|
||||||
|
HousePosition: housePosition ?? (static () => null),
|
||||||
|
TemplateResolver: FakeHotspotTemplate,
|
||||||
|
IconBuilder: FakeIconBuilder),
|
||||||
|
House: new HousePageController.Bindings(
|
||||||
|
Lines: houseLines ?? (static () => Array.Empty<string>()),
|
||||||
|
OnShown: () => calls.Add("house-shown"),
|
||||||
|
TemplateResolver: FakeHouseRowTemplate));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_RootBuildsAsUiTabPanel()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
|
||||||
|
MapHousePanelController? controller =
|
||||||
|
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
|
||||||
|
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
Assert.IsType<UiTabPanel>(controller!.Root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pins the authored tab table exactly as read from the live
|
||||||
|
/// DATs (<c>MapHousePanelSlotProbeTests</c>): two entries, Map is the
|
||||||
|
/// sole default.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void TabTable_MatchesLiveDatPairing_MapIsDefault()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
var tabs = Assert.IsType<UiTabPanel>(layout.Root);
|
||||||
|
|
||||||
|
Assert.Equal(2, tabs.Tabs.Count);
|
||||||
|
Assert.Contains(tabs.Tabs, e =>
|
||||||
|
e.ButtonElementId == 0x100001F3u && e.PageElementId == 0x100001F6u && e.IsDefault);
|
||||||
|
Assert.Contains(tabs.Tabs, e =>
|
||||||
|
e.ButtonElementId == 0x100001F4u && e.PageElementId == 0x100001F7u && !e.IsDefault);
|
||||||
|
Assert.Single(tabs.Tabs, e => e.IsDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_Succeeds_AndActivateTabs_SelectsMapByDefault()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
MapHousePanelController? controller =
|
||||||
|
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
|
||||||
|
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
controller!.ActivateTabs();
|
||||||
|
|
||||||
|
Assert.Empty(controller.TabPanel.UnresolvedEntries);
|
||||||
|
Assert.False(controller.IsShowingHouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwitchToHouse_FiresOnShown_OnlyWhenPanelIsVisible()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
var calls = new List<string>();
|
||||||
|
MapHousePanelController? controller =
|
||||||
|
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls));
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
controller!.ActivateTabs();
|
||||||
|
|
||||||
|
// Not visible yet: switching tabs must not fire OnShown.
|
||||||
|
controller.TabPanel.SwitchTo(0x100001F7u);
|
||||||
|
Assert.DoesNotContain("house-shown", calls);
|
||||||
|
|
||||||
|
controller.OnShown();
|
||||||
|
Assert.Contains("house-shown", calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CloseButton_InvokesToggle()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
var calls = new List<string>();
|
||||||
|
MapHousePanelController? controller =
|
||||||
|
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls));
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
UiElement? close = layout.FindElement(0x100001F5u);
|
||||||
|
Assert.IsType<UiButton>(close);
|
||||||
|
((UiButton)close!).OnClick?.Invoke();
|
||||||
|
|
||||||
|
Assert.Contains("toggle", calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_BuildsAll53TownHotspots_UnderTheMapWidget()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
MapHousePanelController? controller =
|
||||||
|
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
UiElement? map = UiElement.FindDescendant(controller!.Root, MapPageController.MapWidgetId);
|
||||||
|
Assert.NotNull(map);
|
||||||
|
// m_pMap's own children are the player/house icons (found under the
|
||||||
|
// panel-slot resolve tree and rebuilt via Bindings.IconBuilder — see
|
||||||
|
// MapPageController.Bind's doc on why m_pMap being a Button swallows
|
||||||
|
// its authored nested children) PLUS the 53 town hotspots.
|
||||||
|
var townMarkers = map!.Children
|
||||||
|
.Where(c => c.DatElementId != MapPageController.PlayerIconId
|
||||||
|
&& c.DatElementId != MapPageController.HouseIconId)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(55, map.Children.Count);
|
||||||
|
Assert.Equal(53, townMarkers.Count);
|
||||||
|
Assert.All(townMarkers, c => Assert.Contains(
|
||||||
|
MapLocations.All, loc => loc.Width == c.Width && loc.Height == c.Height));
|
||||||
|
// Runtime tooltip text (UiButton.TooltipText, backing
|
||||||
|
// GetTooltipText()) — the retail SetTooltip/m_TTText mechanism, NOT
|
||||||
|
// the DAT-authored AuthoredTooltipText path. The popup-skin locator
|
||||||
|
// is unconditionally required even on the runtime-text path (see
|
||||||
|
// RetailTooltipPresenter.SharedPopupSkinRootElementId's doc).
|
||||||
|
Assert.All(townMarkers, c => Assert.NotEqual(0u, c.AuthoredTooltipRootElementId));
|
||||||
|
Assert.All(townMarkers, c => Assert.NotEqual(0u, c.AuthoredTooltipLayoutDid));
|
||||||
|
Assert.All(townMarkers, c => Assert.IsType<UiButton>(c));
|
||||||
|
Assert.All(townMarkers, c => Assert.False(string.IsNullOrEmpty(((UiButton)c).TooltipText)));
|
||||||
|
Assert.Contains(townMarkers, c => ((UiButton)c).TooltipText == "Holtburg");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_HouseListBoxStartsEmpty_MatchingRetailPostInit()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
MapHousePanelController? controller =
|
||||||
|
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks());
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
UiElement? box = UiElement.FindDescendant(controller!.Root, HousePageController.TextBoxId);
|
||||||
|
var listBox = Assert.IsType<UiTemplateListBox>(box);
|
||||||
|
Assert.Equal(0, listBox.ContentHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch C House-ownership-text closer (2026-08-17): the ONE
|
||||||
|
/// decomp-verified <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>
|
||||||
|
/// line a houseless character's HouseQuery response renders — proves
|
||||||
|
/// <see cref="HousePageController"/>'s revision-gated
|
||||||
|
/// <see cref="MapHousePanelController.Tick"/> poll actually rebuilds the
|
||||||
|
/// authored row template with real text end-to-end, the same way
|
||||||
|
/// <see cref="RuntimeHouseStateTests"/> proves the text composition in
|
||||||
|
/// isolation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Tick_RendersHouseLinesIntoTheAuthoredRowTemplate()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
string[] lines = ["You may buy another house immediately."];
|
||||||
|
MapHousePanelController? controller = MapHousePanelController.Bind(
|
||||||
|
rootInfo, layout, MakeCallbacks(houseLines: () => lines));
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
controller!.Tick(0.016);
|
||||||
|
|
||||||
|
UiElement? box = UiElement.FindDescendant(controller.Root, HousePageController.TextBoxId);
|
||||||
|
var listBox = Assert.IsType<UiTemplateListBox>(box);
|
||||||
|
// Rows land in the ListBox's internal scrollable viewport (AddChild
|
||||||
|
// there, not directly on the ListBox itself — UiTemplateListBox's
|
||||||
|
// own #372/#412 dormancy machinery), exposed to tests via
|
||||||
|
// ViewportForTest.
|
||||||
|
UiScrollablePanel viewport = Assert.IsType<UiScrollablePanel>(
|
||||||
|
listBox.ViewportForTest);
|
||||||
|
Assert.Single(viewport.Children);
|
||||||
|
var row = Assert.IsType<UiText>(viewport.Children[0]);
|
||||||
|
Assert.Equal(
|
||||||
|
"You may buy another house immediately.",
|
||||||
|
Assert.Single(row.LinesProvider()).Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
using System.IO;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live-DAT mount pin for the Map tab's player/house marker icons — THE test
|
||||||
|
/// that would have caught the Batch C icon-resolution gap (register row
|
||||||
|
/// AD-108) at commit time.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The fixture-based <see cref="MapHousePanelControllerTests"/> could not
|
||||||
|
/// catch it: their fake resolvers answer ANY id, so the production resolve
|
||||||
|
/// mechanism itself was never exercised against real data. This test
|
||||||
|
/// reproduces <see cref="RetailUiRuntime.MountMapHousePanel"/>'s exact
|
||||||
|
/// recipe against the INSTALLED DATs (sprite/font resolution stubbed —
|
||||||
|
/// structure only, same as every committed-fixture build): the panel-slot
|
||||||
|
/// <c>LayoutImporter.ImportInfos(dats, hostLayoutId, slotElementId)</c>
|
||||||
|
/// import, <see cref="LayoutImporter.Build"/>, real
|
||||||
|
/// import-then-build hotspot/template resolution, and the
|
||||||
|
/// <see cref="MapPageController.Bindings.IconBuilder"/> build seam —
|
||||||
|
/// then asserts the two icons actually materialize as built elements.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Gated like every other installed-DAT family here:
|
||||||
|
/// <c>[InstalledDatFact]</c>, opt in with
|
||||||
|
/// <c>ACDREAM_PROBE_LIVE_MOUNT=1</c> (the App suite's live-DAT baseline
|
||||||
|
/// mode); <c>ACDREAM_DAT_DIR</c> overrides the ordinary
|
||||||
|
/// Documents/Asheron's Call location.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapHousePanelLiveDatMountTests
|
||||||
|
{
|
||||||
|
private static string DatDirectory =>
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents",
|
||||||
|
"Asheron's Call");
|
||||||
|
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void MountRecipe_ResolvesPlayerAndHouseIcons_UnderTheMapWidget()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
|
||||||
|
// ── AD-108's structural facts, pinned ────────────────────────────
|
||||||
|
// 1) A COLD standalone re-import starting from the icon's own id
|
||||||
|
// returns null: the raw LayoutDesc Elements-table walk never
|
||||||
|
// reaches m_pMap's nested children. This is WHY the IconBuilder
|
||||||
|
// seam exists — if a future DAT regeneration makes this resolve,
|
||||||
|
// this pin flags that the seam could be revisited.
|
||||||
|
Assert.Null(LayoutImporter.ImportInfos(
|
||||||
|
dats,
|
||||||
|
MapHousePanelController.HostLayoutId,
|
||||||
|
MapPageController.PlayerIconId));
|
||||||
|
Assert.Null(LayoutImporter.ImportInfos(
|
||||||
|
dats,
|
||||||
|
MapHousePanelController.HostLayoutId,
|
||||||
|
MapPageController.HouseIconId));
|
||||||
|
|
||||||
|
// 2) The full panel-slot resolve — what MountMapHousePanel actually
|
||||||
|
// imports — DOES materialize both icons, nested under m_pMap.
|
||||||
|
ElementInfo? rootInfo = LayoutImporter.ImportInfos(
|
||||||
|
dats,
|
||||||
|
MapHousePanelController.HostLayoutId,
|
||||||
|
MapHousePanelController.SlotElementId);
|
||||||
|
Assert.NotNull(rootInfo);
|
||||||
|
|
||||||
|
// ── The production mount recipe (sprites/fonts stubbed) ──────────
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(rootInfo!, static _ => (0u, 0, 0), null);
|
||||||
|
|
||||||
|
UiElement? ResolveTemplate(uint layoutId, uint elementId)
|
||||||
|
{
|
||||||
|
ElementInfo? info = LayoutImporter.ImportInfos(dats, layoutId, elementId);
|
||||||
|
return info is null
|
||||||
|
? null
|
||||||
|
: LayoutImporter.Build(info, static _ => (0u, 0, 0), null).Root;
|
||||||
|
}
|
||||||
|
|
||||||
|
UiElement? BuildIcon(ElementInfo info)
|
||||||
|
=> LayoutImporter.Build(info, static _ => (0u, 0, 0), null).Root;
|
||||||
|
|
||||||
|
// Mutable cell: Bind sees "no position yet" (0 — the real mount-time
|
||||||
|
// state, the panel mounts before the session enters world), then the
|
||||||
|
// player lands outdoors and the 5 s cadence re-refreshes.
|
||||||
|
uint playerCell = 0u;
|
||||||
|
var callbacks = new MapHousePanelController.Callbacks(
|
||||||
|
Toggle: static () => { },
|
||||||
|
Map: new MapPageController.Bindings(
|
||||||
|
CurrentCalendar: static () => default,
|
||||||
|
PlayerCellId: () => playerCell,
|
||||||
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
||||||
|
TemplateResolver: ResolveTemplate,
|
||||||
|
IconBuilder: BuildIcon),
|
||||||
|
House: new HousePageController.Bindings(
|
||||||
|
Lines: static () => Array.Empty<string>(),
|
||||||
|
TemplateResolver: ResolveTemplate));
|
||||||
|
|
||||||
|
MapHousePanelController? controller =
|
||||||
|
MapHousePanelController.Bind(rootInfo!, layout, callbacks);
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
// ── Pin 1: both icons resolve as BUILT elements ──────────────────
|
||||||
|
UiElement? map = UiElement.FindDescendant(
|
||||||
|
controller!.Root, MapPageController.MapWidgetId);
|
||||||
|
Assert.NotNull(map);
|
||||||
|
|
||||||
|
UiElement? playerIcon = UiElement.FindDescendant(
|
||||||
|
controller.Root, MapPageController.PlayerIconId);
|
||||||
|
UiElement? houseIcon = UiElement.FindDescendant(
|
||||||
|
controller.Root, MapPageController.HouseIconId);
|
||||||
|
Assert.NotNull(playerIcon);
|
||||||
|
Assert.NotNull(houseIcon);
|
||||||
|
|
||||||
|
// Attached directly under m_pMap (PlaceMarker positions them in its
|
||||||
|
// local space), with real authored extents — PlaceMarker's centering
|
||||||
|
// divides the icon's own Width/Height, so a zero-sized build would
|
||||||
|
// silently mis-center every marker.
|
||||||
|
Assert.Same(map, playerIcon!.Parent);
|
||||||
|
Assert.Same(map, houseIcon!.Parent);
|
||||||
|
Assert.True(playerIcon.Width > 0 && playerIcon.Height > 0,
|
||||||
|
$"player icon built with degenerate extent {playerIcon.Width}x{playerIcon.Height}");
|
||||||
|
Assert.True(houseIcon.Width > 0 && houseIcon.Height > 0,
|
||||||
|
$"house icon built with degenerate extent {houseIcon.Width}x{houseIcon.Height}");
|
||||||
|
|
||||||
|
// ── Pin 2: PlaceMarker's writes survive the per-frame layout pass ─
|
||||||
|
// The F1 live finding's second half: the client re-runs the authored
|
||||||
|
// layout pass (parent → child.ApplyAnchor) every frame. Pre-fix the
|
||||||
|
// icon's compatibility anchor captured the authored (0,0) rect while
|
||||||
|
// the panel sat indoors/hidden, then re-asserted it every frame —
|
||||||
|
// a visible ring pinned to m_pMap's top-left corner regardless of
|
||||||
|
// the player's position. Reproduce that exact frame order here.
|
||||||
|
playerIcon.ApplyAnchor(map!.Width, map.Height); // frame while cell unknown
|
||||||
|
houseIcon.ApplyAnchor(map.Width, map.Height);
|
||||||
|
Assert.False(playerIcon.Visible);
|
||||||
|
|
||||||
|
playerCell = 0x11CE0001u; // Arwic (independently pinned:
|
||||||
|
// display coords -88.3 / 62.9)
|
||||||
|
controller.Tick(MapPageController.RefreshIntervalSeconds + 0.01);
|
||||||
|
Assert.True(playerIcon.Visible);
|
||||||
|
|
||||||
|
(float expectedLeft, float expectedTop) = MapPageController.ComputeMarkerPosition(
|
||||||
|
markerX0: 6, markerX1: 247, markerY0: 8, markerY1: 258,
|
||||||
|
(int)playerIcon.Width, (int)playerIcon.Height, -88.30000000000001, 62.900000000000006);
|
||||||
|
Assert.Equal(expectedLeft, playerIcon.Left);
|
||||||
|
Assert.Equal(expectedTop, playerIcon.Top);
|
||||||
|
|
||||||
|
playerIcon.ApplyAnchor(map.Width, map.Height); // the next frame's pass
|
||||||
|
Assert.Equal(expectedLeft, playerIcon.Left);
|
||||||
|
Assert.Equal(expectedTop, playerIcon.Top);
|
||||||
|
}
|
||||||
|
}
|
||||||
215
tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs
Normal file
215
tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
using System.IO;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch C (overnight hover/UI round, Map/House toolbar panel) discovery
|
||||||
|
/// probe. Desk research (docs/research/2026-08-17-map-house-recon.md,
|
||||||
|
/// itself built on the FA campaign's already-DAT-verified 16-slot
|
||||||
|
/// <c>gmPanelUI::SetupChildren</c> dump) already resolved host
|
||||||
|
/// <c>0x2100006E</c> slot <c>0x1000018C</c> / <c>RetailPanelCatalog</c> id
|
||||||
|
/// <b>16</b> as the Map/House tab host, and toolbar button
|
||||||
|
/// <c>0x1000019A</c> as the (currently ghosted) button that opens it. This
|
||||||
|
/// probe RE-CONFIRMS both against the live installed DATs rather than
|
||||||
|
/// trusting the committed fixture, and additionally reads the facts the
|
||||||
|
/// decomp explains the MEANING of but not the VALUE of: the tab table, the
|
||||||
|
/// <c>m_pMap</c> marker-area rect + hotspot template attrs (<c>0x47</c>/
|
||||||
|
/// <c>0x48</c>), and the House ListBox's authored default content.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Kept as a permanent env-gated pin (like <c>FaPanelSlotProbeTests</c>) —
|
||||||
|
/// it documents authored truth for future sessions, not a one-shot
|
||||||
|
/// throwaway.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapHousePanelSlotProbeTests
|
||||||
|
{
|
||||||
|
private const uint HostLayoutId = 0x2100006Eu;
|
||||||
|
private const uint SlotElementId = 0x1000018Cu;
|
||||||
|
private const uint ToolbarLayoutId = 0x21000016u;
|
||||||
|
private const uint MapHouseToolbarButtonId = 0x1000019Au;
|
||||||
|
|
||||||
|
// gmMapUI PostInit signature children (pc:171993).
|
||||||
|
private const uint MapDateTimeTextId = 0x100001EBu;
|
||||||
|
private const uint MapWidgetId = 0x100001ECu;
|
||||||
|
private const uint MapPlayerIconId = 0x100001EDu;
|
||||||
|
private const uint MapHouseIconId = 0x100001EEu;
|
||||||
|
private const uint MapCoordinateTextId = 0x100001EFu;
|
||||||
|
|
||||||
|
// gmHouseUI PostInit signature child (pc:172581).
|
||||||
|
private const uint HouseTextBoxId = 0x100001E6u;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProbeMapHousePanelSlot()
|
||||||
|
{
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
|
||||||
|
return;
|
||||||
|
|
||||||
|
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents",
|
||||||
|
"Asheron's Call");
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
// 1) The slot itself — panel id, type, tab table.
|
||||||
|
ElementInfo? slot = LayoutImporter.ImportInfos(dats, HostLayoutId, SlotElementId);
|
||||||
|
if (slot is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[maphouse] slot 0x{SlotElementId:X8} -> IMPORT NULL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string panelId = slot.TryGetEffectiveProperty(0x10000029u, out var p)
|
||||||
|
? $"{p.UnsignedValue} (kind={p.Kind})"
|
||||||
|
: "ABSENT";
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] slot 0x{SlotElementId:X8} panelId={panelId} type={slot.Type} "
|
||||||
|
+ $"({slot.X},{slot.Y} {slot.Width}x{slot.Height}) children={slot.Children.Count} "
|
||||||
|
+ $"tabTableCount={slot.TabTable.Count}");
|
||||||
|
foreach (var tab in slot.TabTable)
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] tab button=0x{tab.ButtonElementId:X8} page=0x{tab.PageElementId:X8} "
|
||||||
|
+ $"default={tab.IsDefault}");
|
||||||
|
|
||||||
|
bool hasMapSignature = FindInfo(slot, MapDateTimeTextId) || FindInfo(slot, MapWidgetId);
|
||||||
|
bool hasHouseSignature = FindInfo(slot, HouseTextBoxId);
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] hasMapSignature={hasMapSignature} hasHouseSignature={hasHouseSignature}");
|
||||||
|
|
||||||
|
// Two levels of children so we can see the tab pages' own shape even
|
||||||
|
// if the recovered signature ids above are absent from this install.
|
||||||
|
foreach (ElementInfo c in slot.Children)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] child 0x{c.Id:X8} type={c.Type} ({c.X},{c.Y} {c.Width}x{c.Height}) "
|
||||||
|
+ $"kids={c.Children.Count}");
|
||||||
|
foreach (ElementInfo g in c.Children)
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] g 0x{g.Id:X8} type={g.Type} ({g.X},{g.Y} {g.Width}x{g.Height}) "
|
||||||
|
+ $"kids={g.Children.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) The toolbar button that should open panel 16.
|
||||||
|
ElementInfo? button = LayoutImporter.ImportInfos(dats, ToolbarLayoutId, MapHouseToolbarButtonId);
|
||||||
|
string buttonPanelId = button is not null && button.TryGetEffectiveProperty(0x10000029u, out var bp)
|
||||||
|
? $"{bp.UnsignedValue} (kind={bp.Kind})"
|
||||||
|
: "ABSENT/NULL";
|
||||||
|
Console.WriteLine($"[maphouse] toolbar button 0x{MapHouseToolbarButtonId:X8} panelId={buttonPanelId}");
|
||||||
|
|
||||||
|
// 3) m_pMap's own marker-area rect + hotspot template attrs.
|
||||||
|
ElementInfo? map = FindDescendant(slot, MapWidgetId);
|
||||||
|
if (map is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[maphouse] m_pMap 0x{MapWidgetId:X8} NOT FOUND under slot");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string x0 = map.TryGetEffectiveProperty(0x1000004Eu, out var vx0) ? vx0.IntegerValue.ToString() : "ABSENT";
|
||||||
|
string x1 = map.TryGetEffectiveProperty(0x1000004Fu, out var vx1) ? vx1.IntegerValue.ToString() : "ABSENT";
|
||||||
|
string y0 = map.TryGetEffectiveProperty(0x10000050u, out var vy0) ? vy0.IntegerValue.ToString() : "ABSENT";
|
||||||
|
string y1 = map.TryGetEffectiveProperty(0x10000051u, out var vy1) ? vy1.IntegerValue.ToString() : "ABSENT";
|
||||||
|
bool hasTemplateElement = map.TryGetEffectiveProperty(0x47u, out var te);
|
||||||
|
bool hasTemplateLayoutDid = map.TryGetEffectiveProperty(0x48u, out var tl);
|
||||||
|
string templateElement = hasTemplateElement
|
||||||
|
? $"0x{te.UnsignedValue:X8} (kind={te.Kind})" : "ABSENT";
|
||||||
|
string templateLayoutDid = hasTemplateLayoutDid
|
||||||
|
? $"0x{tl.UnsignedValue:X8} (kind={tl.Kind})" : "ABSENT";
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] m_pMap 0x{MapWidgetId:X8} markerArea=({x0},{y0})-({x1},{y1}) "
|
||||||
|
+ $"templateElement(attr 0x47)={templateElement} templateLayoutDid(attr 0x48)={templateLayoutDid} "
|
||||||
|
+ $"type={map.Type} children={map.Children.Count}");
|
||||||
|
|
||||||
|
// If both resolved, try importing the actual hotspot template so we
|
||||||
|
// know what widget class AddMapNote instantiates per town.
|
||||||
|
if (hasTemplateElement && hasTemplateLayoutDid && tl.UnsignedValue != 0)
|
||||||
|
{
|
||||||
|
ElementInfo? template = LayoutImporter.ImportInfos(dats, (uint)tl.UnsignedValue, (uint)te.UnsignedValue);
|
||||||
|
Console.WriteLine(template is null
|
||||||
|
? "[maphouse] hotspot template IMPORT NULL"
|
||||||
|
: $"[maphouse] hotspot template type={template.Type} "
|
||||||
|
+ $"({template.Width}x{template.Height}) states={template.States.Count} "
|
||||||
|
+ $"stateMedia={template.StateMedia.Count}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ElementInfo? playerIcon = FindDescendant(slot, MapPlayerIconId);
|
||||||
|
ElementInfo? houseIcon = FindDescendant(slot, MapHouseIconId);
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] playerIcon found={playerIcon is not null} type={playerIcon?.Type} "
|
||||||
|
+ $"stateMedia={playerIcon?.StateMedia.Count}");
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] houseIcon found={houseIcon is not null} type={houseIcon?.Type} "
|
||||||
|
+ $"stateMedia={houseIcon?.StateMedia.Count}");
|
||||||
|
|
||||||
|
// 4) House ListBox default content — dump its own children/text so we
|
||||||
|
// know whether "You do not currently own a house..." is authored
|
||||||
|
// directly in the layout or synthesized at runtime.
|
||||||
|
ElementInfo? houseBox = FindDescendant(slot, HouseTextBoxId);
|
||||||
|
if (houseBox is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[maphouse] house listbox 0x{HouseTextBoxId:X8} NOT FOUND under slot");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] house listbox 0x{HouseTextBoxId:X8} type={houseBox.Type} "
|
||||||
|
+ $"children={houseBox.Children.Count} templateListCount={houseBox.TemplateList.Count} "
|
||||||
|
+ $"scrollbar=0x{houseBox.ScrollbarElementId:X8}");
|
||||||
|
foreach (var t in houseBox.TemplateList)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] template layoutId=0x{t.TemplateLayoutId:X8} elementId=0x{t.TemplateElementId:X8}");
|
||||||
|
ElementInfo? rowTemplate = LayoutImporter.ImportInfos(dats, t.TemplateLayoutId, t.TemplateElementId);
|
||||||
|
if (rowTemplate is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[maphouse] row template IMPORT NULL");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] row template type={rowTemplate.Type} "
|
||||||
|
+ $"({rowTemplate.Width}x{rowTemplate.Height}) kids={rowTemplate.Children.Count}");
|
||||||
|
foreach (ElementInfo rc in rowTemplate.Children)
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] rc 0x{rc.Id:X8} type={rc.Type} ({rc.X},{rc.Y} {rc.Width}x{rc.Height})");
|
||||||
|
}
|
||||||
|
foreach (ElementInfo row in houseBox.Children)
|
||||||
|
{
|
||||||
|
string caption = row.TryGetEffectiveProperty(0x17u, out var cap)
|
||||||
|
? $"StringInfo(table={cap.StringInfoValue.TableId:X},id={cap.StringInfoValue.StringId:X},lit={cap.StringInfoValue.English != 0})"
|
||||||
|
: "NO 0x17";
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[maphouse] row 0x{row.Id:X8} type={row.Type} caption={caption} kids={row.Children.Count}");
|
||||||
|
foreach (ElementInfo g in row.Children)
|
||||||
|
{
|
||||||
|
string gcaption = g.TryGetEffectiveProperty(0x17u, out var gcap)
|
||||||
|
? $"StringInfo(table={gcap.StringInfoValue.TableId:X},id={gcap.StringInfoValue.StringId:X})"
|
||||||
|
: "NO 0x17";
|
||||||
|
Console.WriteLine($"[maphouse] g 0x{g.Id:X8} type={g.Type} caption={gcaption}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FindInfo(ElementInfo info, uint id)
|
||||||
|
{
|
||||||
|
if (info.Id == id) return true;
|
||||||
|
foreach (ElementInfo c in info.Children)
|
||||||
|
if (FindInfo(c, id)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElementInfo? FindDescendant(ElementInfo info, uint id)
|
||||||
|
{
|
||||||
|
if (info.Id == id) return info;
|
||||||
|
foreach (ElementInfo c in info.Children)
|
||||||
|
{
|
||||||
|
ElementInfo? found = FindDescendant(c, id);
|
||||||
|
if (found is not null) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
300
tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs
Normal file
300
tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Ui;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit coverage for <see cref="MapPageController"/>'s pure math: the
|
||||||
|
/// calendar formatter (<c>"Date: %s\nTime: %s"</c>,
|
||||||
|
/// <c>gmMapUI::Update @0x004a1eb0</c>) and the 53-town static table
|
||||||
|
/// (verbatim port of <c>s_rgLocations</c>). Marker-placement math itself is
|
||||||
|
/// <see cref="RadarCoordinates"/>/<see cref="LandDefs.GidToLcoord"/> — both
|
||||||
|
/// already unit-tested elsewhere; this file only proves the wiring
|
||||||
|
/// reproduces their output through the real fixture (no re-derivation).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapPageControllerTests
|
||||||
|
{
|
||||||
|
// ── Calendar formatter ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatDateTime_OrdinaryHour_NoAndHalfSuffix()
|
||||||
|
{
|
||||||
|
var calendar = new DerethDateTime.Calendar(
|
||||||
|
119, DerethDateTime.MonthName.Frostfell, 27, DerethDateTime.HourName.Dawnsong);
|
||||||
|
|
||||||
|
string text = MapPageController.FormatDateTime(calendar);
|
||||||
|
|
||||||
|
Assert.Equal("Date: Frostfell 27, 119 P.Y.\nTime: Dawnsong", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatDateTime_AndHalfHour_RewritesSuffixWithHyphens()
|
||||||
|
{
|
||||||
|
var calendar = new DerethDateTime.Calendar(
|
||||||
|
10, DerethDateTime.MonthName.Morningthaw, 1, DerethDateTime.HourName.MorntideAndHalf);
|
||||||
|
|
||||||
|
string text = MapPageController.FormatDateTime(calendar);
|
||||||
|
|
||||||
|
Assert.Equal("Date: Morningthaw 1, 10 P.Y.\nTime: Morntide-and-Half", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(DerethDateTime.HourName.Darktide, "Darktide")]
|
||||||
|
[InlineData(DerethDateTime.HourName.DarktideAndHalf, "Darktide-and-Half")]
|
||||||
|
[InlineData(DerethDateTime.HourName.Gloaming, "Gloaming")]
|
||||||
|
[InlineData(DerethDateTime.HourName.GloamingAndHalf, "Gloaming-and-Half")]
|
||||||
|
[InlineData(DerethDateTime.HourName.WarmtideAndHalf, "Warmtide-and-Half")]
|
||||||
|
public void FormatDateTime_EveryHourName_MatchesExpectedDisplayText(
|
||||||
|
DerethDateTime.HourName hour, string expectedHourText)
|
||||||
|
{
|
||||||
|
var calendar = new DerethDateTime.Calendar(
|
||||||
|
10, DerethDateTime.MonthName.Morningthaw, 1, hour);
|
||||||
|
|
||||||
|
string text = MapPageController.FormatDateTime(calendar);
|
||||||
|
|
||||||
|
Assert.EndsWith($"Time: {expectedHourText}", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Town table ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLocations_Has53Entries()
|
||||||
|
{
|
||||||
|
Assert.Equal(53, MapLocations.All.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLocations_AllNamesAreUnique()
|
||||||
|
{
|
||||||
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (MapLocation loc in MapLocations.All)
|
||||||
|
Assert.True(names.Add(loc.Name), $"duplicate town name: {loc.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLocations_Holtburg_MatchesDecompiledByteValues()
|
||||||
|
{
|
||||||
|
// s_rgLocations[0x13] (pc:977379): X=0xa4 Y=0x4d W=9 H=8.
|
||||||
|
MapLocation holtburg = Assert.Single(MapLocations.All, l => l.Name == "Holtburg");
|
||||||
|
Assert.Equal(0xa4, holtburg.X);
|
||||||
|
Assert.Equal(0x4d, holtburg.Y);
|
||||||
|
Assert.Equal(9, holtburg.Width);
|
||||||
|
Assert.Equal(8, holtburg.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLocations_EveryRectIsWithinTheMapWidgetsAuthoredExtent()
|
||||||
|
{
|
||||||
|
// m_pMap's own authored size (MapHousePanelSlotProbeTests: markerArea
|
||||||
|
// (6,8)-(247,258) — the widest observed extent). Town rects are
|
||||||
|
// independent of the marker-area rect but should still land inside
|
||||||
|
// a sane 0..300 canvas — a coarse sanity check that the verbatim
|
||||||
|
// port didn't transpose a digit.
|
||||||
|
foreach (MapLocation loc in MapLocations.All)
|
||||||
|
{
|
||||||
|
Assert.InRange(loc.X, 0, 260);
|
||||||
|
Assert.InRange(loc.Y, 0, 260);
|
||||||
|
Assert.InRange(loc.Width, 1, 20);
|
||||||
|
Assert.InRange(loc.Height, 1, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marker placement math (GOLDEN PIXEL values, byte-decoded formula) ──
|
||||||
|
//
|
||||||
|
// gmMapUI::PlaceMarkerOnMap @0x004a18b0. Binary Ninja elides the entire
|
||||||
|
// FPU chain to bare, operand-less _ftol2() calls; the formula below was
|
||||||
|
// recovered by disassembling the raw bytes of the PDB-paired
|
||||||
|
// acclient.exe directly (capstone) — see MapPageController.ComputeMarkerPosition's
|
||||||
|
// doc comment and docs/research/2026-08-17-map-house-recon.md's
|
||||||
|
// corrected PlaceMarkerOnMap entry. Every expected value here is a
|
||||||
|
// LITERAL computed independently from the formula (by hand / an
|
||||||
|
// external script), never by calling the port itself — that is the
|
||||||
|
// whole point of a golden-value test.
|
||||||
|
//
|
||||||
|
// X = m_x0 - w/2 - (int)((m_x1-m_x0+1) * (x*10+1024) * (-1/2048))
|
||||||
|
// Y = m_y0 - h/2 - (int)((m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048))
|
||||||
|
//
|
||||||
|
// Marker area used throughout: (6,8)-(247,258) — the live-fixture value
|
||||||
|
// (MapHousePanelSlotProbeTests). Icon: 10x10 (matches the town hotspot
|
||||||
|
// template's "plain 10x10 hotspot dot" and this test file's own fixture
|
||||||
|
// resolver).
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Canonical case: dead center of Dereth (0.0N/0.0E) -> the marker
|
||||||
|
// area's own true center pixel.
|
||||||
|
[InlineData(0.0, 0.0, 122, 128)]
|
||||||
|
// Far west (x very negative): pixel X moves toward the marker area's
|
||||||
|
// left edge (m_x0=6), well below the center-case 122.
|
||||||
|
[InlineData(-100.0, 0.0, 3, 128)]
|
||||||
|
// Far north (y very positive): pixel Y moves toward the marker area's
|
||||||
|
// top edge (m_y0=8) — the FSUBR north-up flip means +Y in-game means
|
||||||
|
// SMALLER pixel Y, not larger.
|
||||||
|
[InlineData(0.0, 100.0, 122, 5)]
|
||||||
|
// A real town-table entry: Arwic's landblock cell 0x11CE0001 fed
|
||||||
|
// through the ALREADY-VERIFIED RadarCoordinates.TryFromCell (a
|
||||||
|
// different, independently-tested subsystem) to get x=-88.3/y=62.9,
|
||||||
|
// then through the formula above to get the expected pixel.
|
||||||
|
[InlineData(-88.30000000000001, 62.900000000000006, 17, 51)]
|
||||||
|
public void ComputeMarkerPosition_MatchesByteDecodedFormula_GoldenPixels(
|
||||||
|
double x, double y, int expectedLeft, int expectedTop)
|
||||||
|
{
|
||||||
|
(float left, float top) = MapPageController.ComputeMarkerPosition(
|
||||||
|
markerX0: 6, markerX1: 247, markerY0: 8, markerY1: 258,
|
||||||
|
iconWidth: 10, iconHeight: 10, x: x, y: y);
|
||||||
|
|
||||||
|
Assert.Equal(expectedLeft, left);
|
||||||
|
Assert.Equal(expectedTop, top);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeMarkerPosition_ArwicCell_MatchesRadarCoordinates()
|
||||||
|
{
|
||||||
|
// Cross-check that the golden (x,y) literal used above for the
|
||||||
|
// "town-table entry" case really is what RadarCoordinates.TryFromCell
|
||||||
|
// produces for Arwic's landblock, so the golden test above can't
|
||||||
|
// silently drift from the coordinate subsystem it's chained to.
|
||||||
|
const uint cellId = 0x11CE0001u;
|
||||||
|
Assert.True(RadarCoordinates.TryFromCell(cellId, out RadarCoordinates coords));
|
||||||
|
Assert.Equal(-88.30000000000001, coords.X, precision: 12);
|
||||||
|
Assert.Equal(62.900000000000006, coords.Y, precision: 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marker placement wiring (real fixture, no re-derivation) ────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_PlayerMarker_OutdoorCell_ReproducesRadarCoordinatesPlacement()
|
||||||
|
{
|
||||||
|
// Arwic's landblock cell id (0x11CE0001 — an arbitrary real outdoor
|
||||||
|
// cell, picked only because RadarCoordinates.TryFromCell already
|
||||||
|
// proves gid-to-lcoord conformance elsewhere; this test proves the
|
||||||
|
// WIRING, not the formula — the formula itself is golden-tested
|
||||||
|
// above).
|
||||||
|
const uint cellId = 0x11CE0001u;
|
||||||
|
Assert.True(RadarCoordinates.TryFromCell(cellId, out _));
|
||||||
|
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
var callbacks = new MapHousePanelController.Callbacks(
|
||||||
|
Toggle: () => { },
|
||||||
|
Map: new MapPageController.Bindings(
|
||||||
|
CurrentCalendar: static () => default,
|
||||||
|
PlayerCellId: () => cellId,
|
||||||
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
||||||
|
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
|
||||||
|
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
|
||||||
|
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
|
||||||
|
|
||||||
|
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
UiElement? playerIcon = UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId);
|
||||||
|
Assert.NotNull(playerIcon);
|
||||||
|
Assert.True(playerIcon!.Visible);
|
||||||
|
|
||||||
|
// markerArea from the live fixture (MapHousePanelSlotProbeTests):
|
||||||
|
// (6,8)-(247,258). GOLDEN pixel value computed independently above
|
||||||
|
// (ComputeMarkerPosition_MatchesByteDecodedFormula_GoldenPixels'
|
||||||
|
// Arwic case) — (17,51) for this exact (x,y).
|
||||||
|
Assert.Equal(17f, playerIcon.Left);
|
||||||
|
Assert.Equal(51f, playerIcon.Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_PlayerMarker_IndoorCell_HidesIconAndClearsCoordinateText()
|
||||||
|
{
|
||||||
|
// Envcell low word (>= 0x100) fails RadarCoordinates.TryFromCell —
|
||||||
|
// the indoor branch (gmMapUI::Update's else: SetVisible(0)).
|
||||||
|
const uint indoorCellId = 0x0012_0100u;
|
||||||
|
Assert.False(RadarCoordinates.TryFromCell(indoorCellId, out _));
|
||||||
|
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
var callbacks = new MapHousePanelController.Callbacks(
|
||||||
|
Toggle: () => { },
|
||||||
|
Map: new MapPageController.Bindings(
|
||||||
|
CurrentCalendar: static () => default,
|
||||||
|
PlayerCellId: () => indoorCellId,
|
||||||
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
||||||
|
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
|
||||||
|
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
|
||||||
|
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
|
||||||
|
|
||||||
|
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
UiElement? playerIcon = UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId);
|
||||||
|
Assert.NotNull(playerIcon);
|
||||||
|
Assert.False(playerIcon!.Visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Refresh_PlayerIconResolutionFails_CoordinateTextStaysEmptyToo()
|
||||||
|
{
|
||||||
|
// F15 (night-round review): gmMapUI::Update @0x004a2078's gate is
|
||||||
|
// `if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` — BOTH
|
||||||
|
// widgets present, not "at least one". A player-icon resolution
|
||||||
|
// failure (leaving _playerIcon null, e.g. a future DAT
|
||||||
|
// regression) must skip the coordinate-text write too, not just the
|
||||||
|
// marker placement — the OLD `_coordinateText is null &&
|
||||||
|
// _playerIcon is null` gate only skipped when BOTH were absent, so
|
||||||
|
// it would have written coordinate text here even with a missing
|
||||||
|
// player icon.
|
||||||
|
const uint cellId = 0x11CE0001u; // outdoor: TryFromCell succeeds.
|
||||||
|
Assert.True(RadarCoordinates.TryFromCell(cellId, out _));
|
||||||
|
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
var callbacks = new MapHousePanelController.Callbacks(
|
||||||
|
Toggle: () => { },
|
||||||
|
Map: new MapPageController.Bindings(
|
||||||
|
CurrentCalendar: static () => default,
|
||||||
|
PlayerCellId: () => cellId,
|
||||||
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
||||||
|
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
|
||||||
|
// Simulates the player icon's own build failing
|
||||||
|
// (ResolveSwallowedIcon's null-build path, e.g. a future DAT
|
||||||
|
// regression) while the house icon and every town-marker
|
||||||
|
// resolution still succeed normally.
|
||||||
|
IconBuilder: info => info.Id == MapPageController.PlayerIconId
|
||||||
|
? null
|
||||||
|
: new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
|
||||||
|
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
|
||||||
|
|
||||||
|
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
Assert.Null(
|
||||||
|
UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId));
|
||||||
|
|
||||||
|
var coordinateText = Assert.IsType<UiText>(
|
||||||
|
UiElement.FindDescendant(controller.Root, MapPageController.CoordinateTextId));
|
||||||
|
Assert.Empty(coordinateText.LinesProvider());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_HouseMarker_NullPosition_StaysHidden()
|
||||||
|
{
|
||||||
|
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||||
|
var callbacks = new MapHousePanelController.Callbacks(
|
||||||
|
Toggle: () => { },
|
||||||
|
Map: new MapPageController.Bindings(
|
||||||
|
CurrentCalendar: static () => default,
|
||||||
|
PlayerCellId: static () => 0u,
|
||||||
|
HousePosition: static () => (CreateObject.ServerPosition?)null,
|
||||||
|
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
|
||||||
|
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
|
||||||
|
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
|
||||||
|
|
||||||
|
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
|
||||||
|
UiElement? houseIcon = UiElement.FindDescendant(controller!.Root, MapPageController.HouseIconId);
|
||||||
|
Assert.NotNull(houseIcon);
|
||||||
|
Assert.False(houseIcon!.Visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -153,6 +153,11 @@ public sealed class RetailLayoutFixtureGenerator
|
||||||
// lane-A unknowns U3/U4/U6/U7/U10.
|
// lane-A unknowns U3/U4/U6/U7/U10.
|
||||||
(SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId,
|
(SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId,
|
||||||
"social_panel_2100006E_1000018F.json"),
|
"social_panel_2100006E_1000018F.json"),
|
||||||
|
// Batch C (overnight hover/UI round, 2026-08-17): the two-tab
|
||||||
|
// Map/House panel — recon doc
|
||||||
|
// docs/research/2026-08-17-map-house-recon.md.
|
||||||
|
(MapHousePanelController.HostLayoutId, MapHousePanelController.SlotElementId,
|
||||||
|
"map_house_2100006E_1000018C.json"),
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
|
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,128 @@ public sealed class RetailTooltipPresenterTests
|
||||||
Assert.Empty(requests);
|
Assert.Empty(requests);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorldHover_FoundObjectChangesDirectly_ReplacesThePopupWithoutStacking()
|
||||||
|
{
|
||||||
|
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1):
|
||||||
|
// the regression that filled the user's screen with dozens of
|
||||||
|
// stacked tooltips. Walking past a run of NPCs/doors/lifestones never
|
||||||
|
// produces a frame where the found guid is 0 — it goes straight from
|
||||||
|
// A to B to C. RecvNotice_SmartBoxObjectFound-equivalent must still
|
||||||
|
// only ever have ONE popup mounted: found A, then found B (no
|
||||||
|
// intervening "nothing found" tick) must swap the popup, not add a
|
||||||
|
// second one on top of the first.
|
||||||
|
const uint otherGuid = 0x80000456u;
|
||||||
|
var (root, presenter, requests) = CreateHarness();
|
||||||
|
uint current = WorldFoundGuid;
|
||||||
|
presenter.WorldHoverGuidProvider = () => current;
|
||||||
|
presenter.WorldHoverNameResolver = guid =>
|
||||||
|
guid == WorldFoundGuid ? "A Drudge" : "A Door";
|
||||||
|
presenter.WorldTooltipsEnabled = () => true;
|
||||||
|
int childrenBefore = root.Children.Count;
|
||||||
|
|
||||||
|
presenter.Tick();
|
||||||
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
||||||
|
|
||||||
|
current = otherGuid;
|
||||||
|
presenter.Tick();
|
||||||
|
|
||||||
|
// Exactly one popup, not two stacked.
|
||||||
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
||||||
|
Assert.Equal(2, requests.Count);
|
||||||
|
|
||||||
|
current = WorldFoundGuid;
|
||||||
|
presenter.Tick();
|
||||||
|
current = otherGuid;
|
||||||
|
presenter.Tick();
|
||||||
|
current = WorldFoundGuid;
|
||||||
|
presenter.Tick();
|
||||||
|
|
||||||
|
// Several more A/B/A swaps still leave exactly one popup mounted —
|
||||||
|
// this is the "dozens of stacked name boxes" scenario, minus the bug.
|
||||||
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks()
|
||||||
|
{
|
||||||
|
// The other half of the "no stacking" contract: a world tooltip
|
||||||
|
// showing, then the mouse settles on a real UI element (dwell path)
|
||||||
|
// — OnTooltipShow's own unconditional RemovePopup() must clear the
|
||||||
|
// world popup, leaving exactly one popup (the UI one), not two.
|
||||||
|
var (root, presenter, _) = CreateHarness();
|
||||||
|
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
|
||||||
|
presenter.WorldHoverNameResolver = _ => "A Drudge";
|
||||||
|
presenter.WorldTooltipsEnabled = () => true;
|
||||||
|
int childrenBefore = root.Children.Count;
|
||||||
|
|
||||||
|
presenter.Tick();
|
||||||
|
Assert.Equal(childrenBefore + 1, root.Children.Count); // world tooltip up
|
||||||
|
|
||||||
|
var target = AddFullyAuthoredTarget(root);
|
||||||
|
root.OnMouseMove(110, 110);
|
||||||
|
root.Tick(0.016, 0);
|
||||||
|
root.Tick(0.016, root.TooltipDelayMs);
|
||||||
|
|
||||||
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
||||||
|
Assert.NotNull(popup);
|
||||||
|
// childrenBefore world-target(none) + target(1) + popup(1) == +2 total,
|
||||||
|
// never +3 (world popup replaced, not stacked).
|
||||||
|
Assert.Equal(childrenBefore + 2, root.Children.Count);
|
||||||
|
|
||||||
|
// Night-round review F6: the test previously stopped here, which
|
||||||
|
// only proved OnTooltipShow's OWN unconditional RemovePopup()
|
||||||
|
// cleared the world popup — it never actually exercised what
|
||||||
|
// happens on the NEXT presenter.Tick() (UpdateWorldHoverTooltip
|
||||||
|
// still thinks a world-hover target exists, since its own
|
||||||
|
// _worldHoverGuid/_worldTooltipShowing bookkeeping was never
|
||||||
|
// re-evaluated after the transition). The mouse is now over the UI
|
||||||
|
// target, so Pick(...) finds it and WorldHoverGuidProvider is
|
||||||
|
// ignored (found=0u) — this must leave the UI popup exactly as-is,
|
||||||
|
// no incorrect extra removal or re-mount.
|
||||||
|
presenter.Tick();
|
||||||
|
Assert.Equal(childrenBefore + 2, root.Children.Count);
|
||||||
|
Assert.Same(popup, root.Children.Single(c => !ReferenceEquals(c, target)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiDwellTooltip_ThenModalStealsHitTesting_WorldHoverReplacesRatherThanStacks()
|
||||||
|
{
|
||||||
|
// Night-round review F5's own reproduction: the UI->world hole. A
|
||||||
|
// UI element's dwell tooltip is showing; a modal then opens WITHOUT
|
||||||
|
// the mouse moving (UiRoot.Modal claims EXCLUSIVE hit-testing —
|
||||||
|
// HitTestTopDown @0x... "Modal gets exclusive hit-test" — so
|
||||||
|
// Pick(MouseX, MouseY) now returns null even though the tooltip's
|
||||||
|
// owner widget is still mounted, still visible, and its popup is
|
||||||
|
// still up). UpdateWorldHoverTooltip's own clear is gated on
|
||||||
|
// _worldTooltipShowing, which is FALSE here (the currently-mounted
|
||||||
|
// popup is UI-owned, not world-owned) — pre-fix, this let the world
|
||||||
|
// path mount a SECOND popup on top without ever clearing the first.
|
||||||
|
var (root, presenter, _) = CreateHarness();
|
||||||
|
var target = AddFullyAuthoredTarget(root);
|
||||||
|
int childrenBefore = root.Children.Count;
|
||||||
|
|
||||||
|
root.OnMouseMove(110, 110);
|
||||||
|
root.Tick(0.016, 0);
|
||||||
|
root.Tick(0.016, root.TooltipDelayMs);
|
||||||
|
Assert.Equal(childrenBefore + 1, root.Children.Count); // UI tooltip up
|
||||||
|
|
||||||
|
// Modal opens elsewhere on screen, stealing exclusive hit-testing —
|
||||||
|
// the mouse never moves.
|
||||||
|
root.Modal = new UiPanel { Left = 0, Top = 0, Width = 10, Height = 10 };
|
||||||
|
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
|
||||||
|
presenter.WorldHoverNameResolver = _ => "A Drudge";
|
||||||
|
presenter.WorldTooltipsEnabled = () => true;
|
||||||
|
|
||||||
|
presenter.Tick();
|
||||||
|
|
||||||
|
// Exactly one popup (the world one, having replaced the UI one) —
|
||||||
|
// never two stacked.
|
||||||
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
||||||
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
||||||
|
Assert.NotNull(popup);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WorldHover_ReEvaluatesGateAndTextOnlyOnTheFoundGuidEdge()
|
public void WorldHover_ReEvaluatesGateAndTextOnlyOnTheFoundGuidEdge()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,13 @@ public sealed class SpellcastingUiControllerTests
|
||||||
CurrentlyEquippedLocation = EquipMask.Held,
|
CurrentlyEquippedLocation = EquipMask.Held,
|
||||||
SpellId = 2670u,
|
SpellId = 2670u,
|
||||||
IconId = 0x06001234u,
|
IconId = 0x06001234u,
|
||||||
|
// TS-85: gmSpellcastingUI::UpdateCastButtonTooltip's endowment
|
||||||
|
// branch (0x004c6a30) gates immediate-use on
|
||||||
|
// ItemUses::IsUseable_SelfTarget (0x004fcd30) reading the ITEM's
|
||||||
|
// own Useability target mask -- a self-castable orb authors the
|
||||||
|
// Self target bit so clicking Cast fires without a target
|
||||||
|
// selection, matching this test's intent.
|
||||||
|
Useability = (ItemUseability.Self << 16) | ItemUseability.Wielded,
|
||||||
});
|
});
|
||||||
controller.Tick();
|
controller.Tick();
|
||||||
|
|
||||||
|
|
@ -711,6 +718,170 @@ public sealed class SpellcastingUiControllerTests
|
||||||
Assert.Null(selection.SelectedObjectId);
|
Assert.Null(selection.SelectedObjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Night-round review F3/F4/F7: cast-button tooltip states ────────────
|
||||||
|
//
|
||||||
|
// gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30, byte-decoded
|
||||||
|
// (see SpellcastingUiController.ComputeSpellCastState/
|
||||||
|
// ComposeEndowmentName's own doc comments). These pin the four
|
||||||
|
// plain-spell states (two pre-existed only as a bare-name fallback; the
|
||||||
|
// two DISABLED states are genuinely new coverage per F7) and the
|
||||||
|
// endowment branch's composed-name fix (F4).
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CastAvailability_UntargetedSpell_IsEnabled_WithCastSpellNameTooltip()
|
||||||
|
{
|
||||||
|
SpellMetadata spell = BuildSpell(
|
||||||
|
42u, "Test Untargeted", isUntargeted: true, isSelfTargeted: false, targetMask: 0u);
|
||||||
|
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||||
|
spellbook.OnSpellLearned(42u);
|
||||||
|
spellbook.SetFavorite(0, 0, 42u);
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(
|
||||||
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||||
|
|
||||||
|
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
|
||||||
|
|
||||||
|
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||||
|
Assert.True(cast.Enabled);
|
||||||
|
Assert.Equal("CAST Test Untargeted", cast.TooltipText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CastAvailability_TargetedSpell_CompatibleTargetSelected_AppendsOnTargetName()
|
||||||
|
{
|
||||||
|
SpellMetadata spell = BuildSpell(
|
||||||
|
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||||
|
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||||
|
spellbook.OnSpellLearned(42u);
|
||||||
|
spellbook.SetFavorite(0, 0, 42u);
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 5u, Name = "Drudge" });
|
||||||
|
var selection = new SelectionState();
|
||||||
|
var operations = new ConfigurableSpellCastOperations { TargetCompatible = true };
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(
|
||||||
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||||
|
|
||||||
|
using SpellcastingUiController controller = Bind(
|
||||||
|
layout, spellbook, objects, _ => { }, selection: selection, operations: operations)!;
|
||||||
|
selection.Select(5u, SelectionChangeSource.World);
|
||||||
|
|
||||||
|
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||||
|
Assert.True(cast.Enabled);
|
||||||
|
Assert.Equal("CAST Test Targeted on Drudge", cast.TooltipText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CastAvailability_TargetedSpell_NoTargetSelected_IsDisabled_WithNeedsTargetTooltip()
|
||||||
|
{
|
||||||
|
// F7: this DISABLED state was previously untested — the bare-name
|
||||||
|
// fallback the old code shipped never distinguished it from the
|
||||||
|
// ready/enabled case.
|
||||||
|
SpellMetadata spell = BuildSpell(
|
||||||
|
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||||
|
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||||
|
spellbook.OnSpellLearned(42u);
|
||||||
|
spellbook.SetFavorite(0, 0, 42u);
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(
|
||||||
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||||
|
|
||||||
|
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
|
||||||
|
|
||||||
|
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||||
|
Assert.False(cast.Enabled);
|
||||||
|
Assert.Equal("You must select a target for Test Targeted", cast.TooltipText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CastAvailability_TargetedSpell_IncompatibleTargetSelected_IsDisabled_WithNeedsAppropriateTargetTooltip()
|
||||||
|
{
|
||||||
|
// F7: this DISABLED state was previously untested.
|
||||||
|
SpellMetadata spell = BuildSpell(
|
||||||
|
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||||
|
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||||
|
spellbook.OnSpellLearned(42u);
|
||||||
|
spellbook.SetFavorite(0, 0, 42u);
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 5u, Name = "Drudge" });
|
||||||
|
var selection = new SelectionState();
|
||||||
|
var operations = new ConfigurableSpellCastOperations { TargetCompatible = false };
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(
|
||||||
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||||
|
|
||||||
|
using SpellcastingUiController controller = Bind(
|
||||||
|
layout, spellbook, objects, _ => { }, selection: selection, operations: operations)!;
|
||||||
|
selection.Select(5u, SelectionChangeSource.World);
|
||||||
|
|
||||||
|
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||||
|
Assert.False(cast.Enabled);
|
||||||
|
Assert.Equal("You must select an appropriate target for Test Targeted", cast.TooltipText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CastAvailability_EndowmentSelfTarget_ComposesItemAndSpellName()
|
||||||
|
{
|
||||||
|
SpellMetadata spell = BuildSpell(
|
||||||
|
2670u, "Lightning Bolt VI", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||||
|
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 2u,
|
||||||
|
Name = "Lightning Wand",
|
||||||
|
Type = ItemType.Caster,
|
||||||
|
WielderId = 1u,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.Held,
|
||||||
|
SpellId = 2670u,
|
||||||
|
// ItemUseability.Self shifted into the TARGET half.
|
||||||
|
Useability = ItemUseability.Self << 16,
|
||||||
|
});
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(
|
||||||
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||||
|
|
||||||
|
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
|
||||||
|
|
||||||
|
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||||
|
Assert.True(cast.Enabled);
|
||||||
|
Assert.Equal("USE the Lightning Wand (Lightning Bolt VI)", cast.TooltipText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CastAvailability_EndowmentNeedsTarget_NoTargetSelected_ComposesItemAndSpellName()
|
||||||
|
{
|
||||||
|
SpellMetadata spell = BuildSpell(
|
||||||
|
2670u, "Lightning Bolt VI", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||||
|
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 2u,
|
||||||
|
Name = "Lightning Wand",
|
||||||
|
Type = ItemType.Caster,
|
||||||
|
WielderId = 1u,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.Held,
|
||||||
|
SpellId = 2670u,
|
||||||
|
// Remote (not Self) shifted into the TARGET half — requires an
|
||||||
|
// external target, same as retail's non-self-castable wands.
|
||||||
|
Useability = ItemUseability.Remote << 16,
|
||||||
|
});
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(
|
||||||
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||||
|
|
||||||
|
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
|
||||||
|
|
||||||
|
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||||
|
Assert.False(cast.Enabled);
|
||||||
|
Assert.Equal(
|
||||||
|
"You must select a target for the Lightning Wand (Lightning Bolt VI)",
|
||||||
|
cast.TooltipText);
|
||||||
|
}
|
||||||
|
|
||||||
private static SpellcastingUiController? Bind(
|
private static SpellcastingUiController? Bind(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Spellbook spellbook,
|
Spellbook spellbook,
|
||||||
|
|
@ -720,13 +891,14 @@ public sealed class SpellcastingUiControllerTests
|
||||||
UiShortcutDigitGraphics? shortcutDigits = null,
|
UiShortcutDigitGraphics? shortcutDigits = null,
|
||||||
uint emptySlotSprite = 0u,
|
uint emptySlotSprite = 0u,
|
||||||
SelectionState? selection = null,
|
SelectionState? selection = null,
|
||||||
Action<uint>? examineSpell = null)
|
Action<uint>? examineSpell = null,
|
||||||
|
IRuntimeSpellCastOperations? operations = null)
|
||||||
{
|
{
|
||||||
SelectionState selectionState = selection ?? new SelectionState();
|
SelectionState selectionState = selection ?? new SelectionState();
|
||||||
var casting = new RuntimeSpellCastState(
|
var casting = new RuntimeSpellCastState(
|
||||||
spellbook,
|
spellbook,
|
||||||
selectionState,
|
selectionState,
|
||||||
new NoopSpellCastOperations());
|
operations ?? new NoopSpellCastOperations());
|
||||||
return SpellcastingUiController.Bind(
|
return SpellcastingUiController.Bind(
|
||||||
layout, spellbook, casting, objects, () => 1u,
|
layout, spellbook, casting, objects, () => 1u,
|
||||||
spellId => spellId,
|
spellId => spellId,
|
||||||
|
|
@ -760,6 +932,57 @@ public sealed class SpellcastingUiControllerTests
|
||||||
public void IncrementBusy() { }
|
public void IncrementBusy() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Same as <see cref="NoopSpellCastOperations"/> but with a
|
||||||
|
/// settable target-compatibility answer — the night-round review
|
||||||
|
/// (F3/F7) cast-button-tooltip tests need to force both the
|
||||||
|
/// compatible and incompatible target branches.</summary>
|
||||||
|
private sealed class ConfigurableSpellCastOperations : IRuntimeSpellCastOperations
|
||||||
|
{
|
||||||
|
public bool TargetCompatible = true;
|
||||||
|
public uint LocalPlayerId => 1u;
|
||||||
|
public bool CanSend => true;
|
||||||
|
public bool HasRequiredComponents(uint spellId) => true;
|
||||||
|
public bool IsTargetCompatible(
|
||||||
|
uint targetId,
|
||||||
|
SpellMetadata spell,
|
||||||
|
bool showMessage) => TargetCompatible;
|
||||||
|
public void StopCompletely() { }
|
||||||
|
public void SendUntargeted(uint spellId) { }
|
||||||
|
public void SendTargeted(uint targetId, uint spellId) { }
|
||||||
|
public void DisplayMessage(string message) { }
|
||||||
|
public void IncrementBusy() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal <see cref="SpellMetadata"/> builder for cast-gate
|
||||||
|
/// tests — most of the record's fields are irrelevant to
|
||||||
|
/// <see cref="RuntimeSpellCastState.EvaluateCastGate"/>.</summary>
|
||||||
|
private static SpellMetadata BuildSpell(
|
||||||
|
uint spellId, string name, bool isUntargeted, bool isSelfTargeted, uint targetMask) =>
|
||||||
|
new(
|
||||||
|
SpellId: spellId,
|
||||||
|
Name: name,
|
||||||
|
School: "Life",
|
||||||
|
Family: 0u,
|
||||||
|
IconId: 0u,
|
||||||
|
SpellWords: "",
|
||||||
|
Duration: 0f,
|
||||||
|
ManaCost: 0,
|
||||||
|
IsDebuff: false,
|
||||||
|
IsFellowship: false,
|
||||||
|
Description: "",
|
||||||
|
SortKey: 0,
|
||||||
|
Difficulty: 0,
|
||||||
|
Flags: isSelfTargeted ? (uint)SpellFlags.SelfTargeted : 0u,
|
||||||
|
Generation: 1,
|
||||||
|
IsFastWindup: false,
|
||||||
|
IsOffensive: false,
|
||||||
|
IsUntargeted: isUntargeted,
|
||||||
|
Speed: 0f,
|
||||||
|
CasterEffect: 0u,
|
||||||
|
TargetEffect: 0u,
|
||||||
|
TargetMask: targetMask,
|
||||||
|
SpellType: 0);
|
||||||
|
|
||||||
private static void ApplyAnchors(UiElement parent)
|
private static void ApplyAnchors(UiElement parent)
|
||||||
{
|
{
|
||||||
foreach (UiElement child in parent.Children)
|
foreach (UiElement child in parent.Children)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -76,4 +76,75 @@ public sealed class UiTemplateListBoxViewportTests
|
||||||
Assert.True(row1.Visible, "row 1 culled — the #372 blank-tab bug");
|
Assert.True(row1.Visible, "row 1 culled — the #372 blank-tab bug");
|
||||||
Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug");
|
Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #412-class regression (2026-08-16, overnight hover/UI round, Batch A bug
|
||||||
|
/// 2): the Options panel's Config tab escaped past the window frame — the
|
||||||
|
/// footer sitting mid-panel with further rows drawing below the window's
|
||||||
|
/// bottom edge. #372's own fixture above never exercises this because
|
||||||
|
/// <see cref="MakeListBox"/> gives the ListBox no parent — its own size
|
||||||
|
/// never changes after the viewport is seeded. The real Options mount is
|
||||||
|
/// different: this ListBox is itself a DAT-imported <see cref="UiElement"/>
|
||||||
|
/// carrying a real <see cref="UiLayoutPolicy"/> from its authored parent
|
||||||
|
/// (the Config page slot), and a page controller's Bind — which lazily
|
||||||
|
/// creates this viewport — runs BEFORE the tree's first draw frame, i.e.
|
||||||
|
/// before the ListBox has ever shrunk to fit its actual (smaller than
|
||||||
|
/// authored) container. This reproduces that ordering with a real
|
||||||
|
/// LayoutPolicy-driven parent standing in for the page slot.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout()
|
||||||
|
{
|
||||||
|
// A stand-in for the Config page slot: authored 298×575 against an
|
||||||
|
// authored 300×600 design canvas (live-DAT-measured values), but its
|
||||||
|
// real mounted container is only 300×362 — exactly retail's
|
||||||
|
// UIElement::UpdateForParentSizeChange four-edge policy (L=T=R=B=1,
|
||||||
|
// "preserve original margin on every edge").
|
||||||
|
var slotPolicy = new UiLayoutPolicy(
|
||||||
|
leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1,
|
||||||
|
originalChild: UiPixelRect.FromPositionAndSize(2, 25, 298, 575),
|
||||||
|
originalParent: UiPixelRect.FromPositionAndSize(0, 0, 300, 600));
|
||||||
|
var slot = new UiPanel
|
||||||
|
{
|
||||||
|
Left = 2, Top = 25, Width = 298, Height = 575,
|
||||||
|
LayoutPolicy = slotPolicy,
|
||||||
|
};
|
||||||
|
var root = new UiPanel { Width = 300, Height = 362 };
|
||||||
|
root.AddChild(slot);
|
||||||
|
|
||||||
|
// The ListBox itself ALSO carries a real LayoutPolicy (live-DAT
|
||||||
|
// measured: authored 276×560 against the slot's own 298×575 design
|
||||||
|
// extent) — this is what shrinks it out from under the viewport.
|
||||||
|
var box = MakeListBox(276f, 560f);
|
||||||
|
var listBoxPolicy = new UiLayoutPolicy(
|
||||||
|
leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1,
|
||||||
|
originalChild: UiPixelRect.FromPositionAndSize(0, 0, 276, 560),
|
||||||
|
originalParent: UiPixelRect.FromPositionAndSize(0, 0, 298, 575));
|
||||||
|
box.LayoutPolicy = listBoxPolicy;
|
||||||
|
slot.AddChild(box);
|
||||||
|
|
||||||
|
// Seed the viewport with rows BEFORE any layout pass has ever run —
|
||||||
|
// exactly ConfigOptionsPageController.Bind's own ordering (it runs
|
||||||
|
// before RetailWindowFrame.Mount's first draw frame).
|
||||||
|
box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f });
|
||||||
|
UiScrollablePanel viewport = box.ViewportForTest!;
|
||||||
|
|
||||||
|
// Drive ONE simulated draw-frame's top-down ApplyAnchor walk — the
|
||||||
|
// SAME order DrawSelfAndChildren runs every frame: parent before
|
||||||
|
// children, all the way down.
|
||||||
|
slot.ApplyAnchor(root.Width, root.Height); // slot shrinks: 575 -> ~337
|
||||||
|
box.ApplyAnchor(slot.Width, slot.Height); // listbox shrinks: 560 -> ~297 (still ahead of the viewport)
|
||||||
|
viewport.ApplyAnchor(box.Width, box.Height); // the viewport's FIRST EVER ApplyAnchor call
|
||||||
|
|
||||||
|
// Pre-fix: the viewport's legacy anchor baseline captured a NEGATIVE
|
||||||
|
// bottom margin against the ALREADY-SHRUNK ListBox (parentH(~297) -
|
||||||
|
// (0+560) < 0), which ComputeAnchoredRect's stretch math preserves
|
||||||
|
// forever — the viewport stayed locked at its original 560px height,
|
||||||
|
// clipping rows to a bound retail never actually gave it on screen.
|
||||||
|
Assert.Equal(box.Height, viewport.Height, 3);
|
||||||
|
Assert.True(
|
||||||
|
viewport.Height < 400f,
|
||||||
|
$"viewport height {viewport.Height} did not shrink with its ListBox "
|
||||||
|
+ "(560 == the pre-fix stale-capture bug)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1771,6 +1771,48 @@ public sealed class GameEventWiringTests
|
||||||
Assert.True(observed.Value.IsLoggedIn);
|
Assert.True(observed.Value.IsLoggedIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Batch C (Map/House toolbar panel, 2026-08-17): all four
|
||||||
|
/// house wire opcodes (0x0225-0x0228) reach their registered
|
||||||
|
/// callbacks.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void WireAll_HouseFamily_ReachesTheirCallbacks()
|
||||||
|
{
|
||||||
|
var dispatcher = new GameEventDispatcher();
|
||||||
|
GameEvents.HouseData? data = null;
|
||||||
|
uint? status = null;
|
||||||
|
uint? rentTime = null;
|
||||||
|
IReadOnlyList<GameEvents.HousePayment>? rentPayment = null;
|
||||||
|
GameEventWiring.WireAll(
|
||||||
|
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||||||
|
onHouseData: d => data = d,
|
||||||
|
onHouseStatus: code => status = code,
|
||||||
|
onHouseUpdateRentTime: t => rentTime = t,
|
||||||
|
onHouseUpdateRentPayment: p => rentPayment = p);
|
||||||
|
|
||||||
|
byte[] houseDataWire = new AceWireWriter()
|
||||||
|
.Write(0u).Write(0u).Write(0u).Write(0u)
|
||||||
|
.Write(0).Write(0)
|
||||||
|
.Write(0x00120001u)
|
||||||
|
.Write(0f).Write(0f).Write(0f)
|
||||||
|
.Write(1f).Write(0f).Write(0f).Write(0f)
|
||||||
|
.ToArray();
|
||||||
|
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||||||
|
WrapEnvelope(GameEventType.HouseData, houseDataWire))!.Value);
|
||||||
|
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
|
||||||
|
GameEventType.HouseStatus, new AceWireWriter().Write(0u).ToArray()))!.Value);
|
||||||
|
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
|
||||||
|
GameEventType.UpdateRentTime, new AceWireWriter().Write(1_700_000_000u).ToArray()))!.Value);
|
||||||
|
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
|
||||||
|
GameEventType.UpdateRentPayment, new AceWireWriter().Write(0).ToArray()))!.Value);
|
||||||
|
|
||||||
|
Assert.NotNull(data);
|
||||||
|
Assert.Equal(0x00120001u, data!.Value.Position.LandblockId);
|
||||||
|
Assert.Equal(0u, status);
|
||||||
|
Assert.Equal(1_700_000_000u, rentTime);
|
||||||
|
Assert.NotNull(rentPayment);
|
||||||
|
Assert.Empty(rentPayment);
|
||||||
|
}
|
||||||
|
|
||||||
private static byte[] BuildEnchantment(
|
private static byte[] BuildEnchantment(
|
||||||
ushort spellId,
|
ushort spellId,
|
||||||
ushort layer,
|
ushort layer,
|
||||||
|
|
|
||||||
156
tests/AcDream.Core.Net.Tests/Messages/HouseEventsTests.cs
Normal file
156
tests/AcDream.Core.Net.Tests/Messages/HouseEventsTests.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch C (overnight hover/UI round, Map/House toolbar panel, 2026-08-17):
|
||||||
|
/// golden-byte coverage for the House panel's four inbound events
|
||||||
|
/// (0x0225-0x0228, gmHouseUI::PostInit's registered notice handlers) and the
|
||||||
|
/// outbound HouseQuery action (0x021E). Wire shapes cross-checked against
|
||||||
|
/// ACE's HouseDataExtensions/HousePaymentExtensions
|
||||||
|
/// (references/ACE/Source/ACE.Server/Network/Structure/HouseData.cs,
|
||||||
|
/// HousePayment.cs) — see docs/research/2026-08-17-map-house-recon.md.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HouseEventsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildHouseQuery_WritesEnvelopeSequenceOpcodeOnly()
|
||||||
|
{
|
||||||
|
byte[] body = ClientCommandRequests.BuildHouseQuery(9);
|
||||||
|
|
||||||
|
Assert.Equal(12, body.Length);
|
||||||
|
Assert.Equal(ClientCommandRequests.HouseQueryOpcode,
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseHouseStatus_ReadsWeenieError()
|
||||||
|
{
|
||||||
|
byte[] wire = new AceWireWriter().Write(0u).ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(0u, GameEvents.ParseHouseStatus(wire));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseHouseStatus_TruncatedPayload_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(GameEvents.ParseHouseStatus(System.Array.Empty<byte>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUpdateRentTime_ReadsTimestamp()
|
||||||
|
{
|
||||||
|
// The real ACE writer always sends 0u (a stub) — parsed at whatever
|
||||||
|
// value arrives, not hardcoded to that stub's output.
|
||||||
|
byte[] wire = new AceWireWriter().Write(1_700_000_000u).ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(1_700_000_000u, GameEvents.ParseUpdateRentTime(wire));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUpdateRentPayment_EmptyList_RoundTrips()
|
||||||
|
{
|
||||||
|
// The real ACE writer always sends an empty list (a stub).
|
||||||
|
byte[] wire = new AceWireWriter().Write(0).ToArray();
|
||||||
|
|
||||||
|
var payments = GameEvents.ParseUpdateRentPayment(wire);
|
||||||
|
|
||||||
|
Assert.NotNull(payments);
|
||||||
|
Assert.Empty(payments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUpdateRentPayment_OneEntry_RoundTrips()
|
||||||
|
{
|
||||||
|
byte[] wire = new AceWireWriter()
|
||||||
|
.Write(1)
|
||||||
|
.Write(400) // Num
|
||||||
|
.Write(150) // Paid
|
||||||
|
.Write(273u) // WeenieID (pyreal)
|
||||||
|
.WriteString16L("Pyreal")
|
||||||
|
.WriteString16L("Pyreals")
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var payments = GameEvents.ParseUpdateRentPayment(wire);
|
||||||
|
|
||||||
|
Assert.NotNull(payments);
|
||||||
|
GameEvents.HousePayment payment = Assert.Single(payments);
|
||||||
|
Assert.Equal(400, payment.Num);
|
||||||
|
Assert.Equal(150, payment.Paid);
|
||||||
|
Assert.Equal(273u, payment.WeenieID);
|
||||||
|
Assert.Equal("Pyreal", payment.Name);
|
||||||
|
Assert.Equal("Pyreals", payment.PluralName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseHouseData_NoHouseOwned_EmptyListsAndZeroFields()
|
||||||
|
{
|
||||||
|
byte[] wire = new AceWireWriter()
|
||||||
|
.Write(0u) // BuyTime
|
||||||
|
.Write(0u) // RentTime
|
||||||
|
.Write(0u) // Type (Undef)
|
||||||
|
.Write(0u) // MaintenanceFree
|
||||||
|
.Write(0) // Buy.Count
|
||||||
|
.Write(0) // Rent.Count
|
||||||
|
// Position: Cell + Pos.XYZ + Rotation.WXYZ
|
||||||
|
.Write(0x00120001u)
|
||||||
|
.Write(10f).Write(20f).Write(30f)
|
||||||
|
.Write(1f).Write(0f).Write(0f).Write(0f)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
GameEvents.HouseData? data = GameEvents.ParseHouseData(wire);
|
||||||
|
|
||||||
|
Assert.NotNull(data);
|
||||||
|
Assert.Equal(0u, data!.Value.BuyTime);
|
||||||
|
Assert.Empty(data.Value.Buy);
|
||||||
|
Assert.Empty(data.Value.Rent);
|
||||||
|
Assert.Equal(0x00120001u, data.Value.Position.LandblockId);
|
||||||
|
Assert.Equal(10f, data.Value.Position.PositionX);
|
||||||
|
Assert.Equal(30f, data.Value.Position.PositionZ);
|
||||||
|
Assert.Equal(1f, data.Value.Position.RotationW);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseHouseData_OwnedHouse_ReadsBuyAndRentLists()
|
||||||
|
{
|
||||||
|
byte[] wire = new AceWireWriter()
|
||||||
|
.Write(1_650_000_000u) // BuyTime
|
||||||
|
.Write(1_699_000_000u) // RentTime
|
||||||
|
.Write(1u) // Type (Cottage)
|
||||||
|
.Write(0u) // MaintenanceFree = false
|
||||||
|
.Write(1) // Buy.Count
|
||||||
|
.Write(1).Write(1).Write(273u)
|
||||||
|
.WriteString16L("Pyreal").WriteString16L("Pyreals")
|
||||||
|
.Write(2) // Rent.Count
|
||||||
|
.Write(300).Write(300).Write(273u)
|
||||||
|
.WriteString16L("Pyreal").WriteString16L("Pyreals")
|
||||||
|
.Write(1).Write(0).Write(1049u)
|
||||||
|
.WriteString16L("Writ of the Chosen").WriteString16L("Writs of the Chosen")
|
||||||
|
// Position
|
||||||
|
.Write(0x00340002u)
|
||||||
|
.Write(-15f).Write(45f).Write(0f)
|
||||||
|
.Write(0.7071f).Write(0f).Write(0f).Write(0.7071f)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
GameEvents.HouseData? data = GameEvents.ParseHouseData(wire);
|
||||||
|
|
||||||
|
Assert.NotNull(data);
|
||||||
|
Assert.Equal(1_650_000_000u, data!.Value.BuyTime);
|
||||||
|
Assert.Equal(1_699_000_000u, data.Value.RentTime);
|
||||||
|
Assert.Equal(1u, data.Value.Type);
|
||||||
|
Assert.False(data.Value.MaintenanceFree);
|
||||||
|
Assert.Single(data.Value.Buy);
|
||||||
|
Assert.Equal(2, data.Value.Rent.Count);
|
||||||
|
Assert.Equal("Writ of the Chosen", data.Value.Rent[1].Name);
|
||||||
|
Assert.Equal(0x00340002u, data.Value.Position.LandblockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseHouseData_TruncatedPayload_ReturnsNull()
|
||||||
|
{
|
||||||
|
byte[] wire = new AceWireWriter().Write(0u).Write(0u).ToArray();
|
||||||
|
|
||||||
|
Assert.Null(GameEvents.ParseHouseData(wire));
|
||||||
|
}
|
||||||
|
}
|
||||||
199
tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs
Normal file
199
tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Properties;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Tests.Gameplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// House-tab conformance (Batch C, 2026-08-17): the ONE decomp-verified
|
||||||
|
/// line <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c> emits for a
|
||||||
|
/// houseless/fresh character, and the wait-period-not-expired case that
|
||||||
|
/// stays empty (unrecoverable strftime format, ISSUES #413 item 2).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RuntimeHouseStateTests
|
||||||
|
{
|
||||||
|
private const uint Self = 0x50000001u;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyBeforeAnyNoticeArrives()
|
||||||
|
{
|
||||||
|
// gmHouseUI::PostInit never calls Update/DisplayHouseData — the
|
||||||
|
// ListBox starts genuinely empty (live-DAT-confirmed: the House
|
||||||
|
// page's ListBox children=0, no other page content).
|
||||||
|
var house = new RuntimeHouseState();
|
||||||
|
|
||||||
|
Assert.Empty(house.Lines);
|
||||||
|
Assert.False(house.HasReceivedNotice);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseStatus_FreshCharacterWithNoTimestamp_ShowsBuyImmediatelyLine()
|
||||||
|
{
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
|
||||||
|
// No PropertyInt.HousePurchaseTimestamp set — absent reads as 0,
|
||||||
|
// matching a fresh character that has never purchased or abandoned
|
||||||
|
// a house. HasPurchaseWaitPeriodExpired(0) is trivially true.
|
||||||
|
var house = new RuntimeHouseState(objects);
|
||||||
|
|
||||||
|
house.ApplyHouseStatus(weenieError: 0u, Self);
|
||||||
|
|
||||||
|
Assert.True(house.HasReceivedNotice);
|
||||||
|
Assert.Equal(["You may buy another house immediately."], house.Lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseStatus_WeenieErrorValueIsDiscarded()
|
||||||
|
{
|
||||||
|
// Decomp-confirmed: gmHouseUI::Update(uint32_t)/gmMapUI::
|
||||||
|
// RecvNotice_FailedHouseTransaction never read their arg2. The
|
||||||
|
// rendered text must not depend on the wire WeenieError value.
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
|
||||||
|
var houseA = new RuntimeHouseState(objects);
|
||||||
|
var houseB = new RuntimeHouseState(objects);
|
||||||
|
|
||||||
|
houseA.ApplyHouseStatus(weenieError: 0u, Self);
|
||||||
|
houseB.ApplyHouseStatus(weenieError: 0x45Fu /* HouseEvicted */, Self);
|
||||||
|
|
||||||
|
Assert.Equal(houseA.Lines, houseB.Lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseData_OwnedHouseWithExpiredWaitPeriod_ShowsAbandonFirstLine()
|
||||||
|
{
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
|
||||||
|
var house = new RuntimeHouseState(objects);
|
||||||
|
|
||||||
|
house.ApplyHouseData(SampleHouseData(), Self);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
["You may buy another house immediately after you abandon this one."],
|
||||||
|
house.Lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseStatus_TimestampWithinThirtyDayWindow_ShowsExpiryDateLine()
|
||||||
|
{
|
||||||
|
// HouseSystem::HasPurchaseWaitPeriodExpired: (now - timestamp) >
|
||||||
|
// 0x278d00 (2,592,000 s = 30 days). Inside the window, retail's
|
||||||
|
// gmHouseUI::DisplayPurchaseTimeText composes prefix + strftime("%c")
|
||||||
|
// of (timestamp + 30 days) + suffix (night-round review F8 — the
|
||||||
|
// "unrecoverable strftime branch" from ISSUES #413 item 2 was
|
||||||
|
// byte-decoded and is now ported). The exact date substring is
|
||||||
|
// locale/timezone-formatted (.NET's honest %c analogue), so this
|
||||||
|
// pins the STRUCTURE (prefix/suffix, non-empty middle), not the
|
||||||
|
// exact rendered date text.
|
||||||
|
var clock = new ManualTimeProvider();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
|
||||||
|
var bundle = new PropertyBundle();
|
||||||
|
bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] =
|
||||||
|
(int)clock.GetUtcNow().ToUnixTimeSeconds();
|
||||||
|
objects.UpsertProperties(Self, bundle);
|
||||||
|
var house = new RuntimeHouseState(objects, clock);
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromDays(29));
|
||||||
|
house.ApplyHouseStatus(weenieError: 0u, Self);
|
||||||
|
|
||||||
|
string line = Assert.Single(house.Lines);
|
||||||
|
Assert.StartsWith("You may buy another landscape house at ", line);
|
||||||
|
Assert.EndsWith(". This restriction does not apply to apartments.", line);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseStatus_TimestampWithinThirtyDayWindow_ExpiryDateIsTimestampPlusThirtyDays()
|
||||||
|
{
|
||||||
|
// Pins the actual computed expiry moment (retail: timestamp +
|
||||||
|
// 0x278d00 = 2,592,000 s, formatted via localtime — this fixture's
|
||||||
|
// LocalTimeZone is UTC, so the rendered date is exactly the UTC
|
||||||
|
// expiry instant with no offset ambiguity).
|
||||||
|
var clock = new ManualTimeProvider();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
|
||||||
|
DateTimeOffset purchaseTime = clock.GetUtcNow();
|
||||||
|
var bundle = new PropertyBundle();
|
||||||
|
bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] =
|
||||||
|
(int)purchaseTime.ToUnixTimeSeconds();
|
||||||
|
objects.UpsertProperties(Self, bundle);
|
||||||
|
var house = new RuntimeHouseState(objects, clock);
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromDays(29));
|
||||||
|
house.ApplyHouseStatus(weenieError: 0u, Self);
|
||||||
|
|
||||||
|
DateTime expectedExpiry = purchaseTime.AddSeconds(0x278d00).UtcDateTime;
|
||||||
|
string line = Assert.Single(house.Lines);
|
||||||
|
Assert.Contains(expectedExpiry.ToString(System.Globalization.CultureInfo.CurrentCulture), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseStatus_TimestampPastThirtyDayWindow_ShowsBuyImmediatelyLine()
|
||||||
|
{
|
||||||
|
var clock = new ManualTimeProvider();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
|
||||||
|
var bundle = new PropertyBundle();
|
||||||
|
bundle.Ints[(uint)PropertyInt.HousePurchaseTimestamp] =
|
||||||
|
(int)clock.GetUtcNow().ToUnixTimeSeconds();
|
||||||
|
objects.UpsertProperties(Self, bundle);
|
||||||
|
var house = new RuntimeHouseState(objects, clock);
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromDays(31));
|
||||||
|
house.ApplyHouseStatus(weenieError: 0u, Self);
|
||||||
|
|
||||||
|
Assert.Equal(["You may buy another house immediately."], house.Lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetSession_RestoresGenuinelyEmptyPreNoticeState()
|
||||||
|
{
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
|
||||||
|
var house = new RuntimeHouseState(objects);
|
||||||
|
house.ApplyHouseStatus(weenieError: 0u, Self);
|
||||||
|
Assert.NotEmpty(house.Lines);
|
||||||
|
|
||||||
|
house.ResetSession();
|
||||||
|
|
||||||
|
Assert.Empty(house.Lines);
|
||||||
|
Assert.False(house.HasReceivedNotice);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingObjectTable_DefaultsTimestampToZero()
|
||||||
|
{
|
||||||
|
// Bare-fixture callers (no ClientObjectTable) must not throw — the
|
||||||
|
// same optional-borrow discipline RuntimeTradeState uses.
|
||||||
|
var house = new RuntimeHouseState();
|
||||||
|
|
||||||
|
house.ApplyHouseStatus(weenieError: 0u, Self);
|
||||||
|
|
||||||
|
Assert.Equal(["You may buy another house immediately."], house.Lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameEvents.HouseData SampleHouseData() => new(
|
||||||
|
BuyTime: 0u,
|
||||||
|
RentTime: 0u,
|
||||||
|
Type: 0u,
|
||||||
|
MaintenanceFree: false,
|
||||||
|
Buy: Array.Empty<GameEvents.HousePayment>(),
|
||||||
|
Rent: Array.Empty<GameEvents.HousePayment>(),
|
||||||
|
Position: new CreateObject.ServerPosition(0u, 0f, 0f, 0f, 1f, 0f, 0f, 0f));
|
||||||
|
|
||||||
|
private sealed class ManualTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
private DateTimeOffset _now = new(2026, 8, 17, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => _now;
|
||||||
|
|
||||||
|
// F8: pin LocalTimeZone to UTC so the not-yet-expired branch's
|
||||||
|
// TimeZoneInfo.ConvertTime call is deterministic across machines —
|
||||||
|
// the real production TimeProvider.System.LocalTimeZone is
|
||||||
|
// TimeZoneInfo.Local, matching retail's own localtime() call.
|
||||||
|
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||||
|
|
||||||
|
public void Advance(TimeSpan elapsed) => _now += elapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -165,10 +165,18 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
||||||
Spawn(playerGuid, incarnation: 1);
|
Spawn(playerGuid, incarnation: 1);
|
||||||
sink.Spawned(spawn);
|
sink.Spawned(spawn);
|
||||||
|
|
||||||
Assert.Single(gameActions);
|
// Night-round review F2: the content-less direct host's terminal
|
||||||
|
// admission edge now also sends HouseQuery (0x021E), matching
|
||||||
|
// retail's CM_House::Event_QueryHouse tail-call from
|
||||||
|
// CPlayerSystem::InitializePlayer right after
|
||||||
|
// AttemptSendLoginCompleteNotification. It's the session's first
|
||||||
|
// NextGameActionSequence() call (LoginComplete doesn't consume one),
|
||||||
|
// so its sequence is deterministically 1.
|
||||||
|
Assert.Equal(2, gameActions.Count);
|
||||||
Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]);
|
Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]);
|
||||||
|
Assert.Equal(ClientCommandRequests.BuildHouseQuery(1u), gameActions[1]);
|
||||||
sink.Spawned(spawn);
|
sink.Spawned(spawn);
|
||||||
Assert.Single(gameActions);
|
Assert.Equal(2, gameActions.Count);
|
||||||
gameActions.Clear();
|
gameActions.Clear();
|
||||||
|
|
||||||
sink.TeleportStarted(1u);
|
sink.TeleportStarted(1u);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue