diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bd214cec..65528fcf 100644 --- a/docs/ISSUES.md +++ b/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. - 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 **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 `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 -`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 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" 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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 13e9c959..e4fc67c5 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 19 active rows (IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) +## 1. Intentional architecture (IA) — 20 active rows (IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -60,10 +60,11 @@ accepted-divergence entries (#96, #49, #50). | IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` | | IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` | | IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 | +| IA-23 | **Filed 2026-08-17 at the night-round review fix round (F8).** `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s not-yet-expired branch renders `"You may buy another landscape house at " + strftime("%c", localtime(timestamp + 0x278d00)) + ". This restriction does not apply to apartments."` — byte-decoded from raw pushed literals at `@0x004a3265`/`@0x004a321d`/`@0x004a3235` (all three text pieces confirmed; a prior filing had wrongly called this "unrecoverable"). This port renders the SAME three pieces, in the same order, with the same expiry-timestamp math, but formats the middle date/time piece with .NET's culture-default `DateTime.ToString()` (no explicit format string) rather than the C runtime's `strftime("%c", ...)` — the two engines do not share a format table, so the RENDERED SHAPE of the date/time differs (e.g. .NET's short numeric date+time vs the CRT's `Ddd Mon DD HH:MM:SS YYYY`-style locale string) even though both express "the process's own locale's full date+time" and use the SAME underlying instant (local time, matching retail's `localtime()`). | `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (`Recompute`'s not-expired branch) | Both are "whatever the process locale says" full date+time strings; no game-logic reads or parses this text back, it is pure chat-scroll presentation, so a differently-shaped (but equally legible) date string carries no functional risk | A retail-side-by-side visual comparison will show a differently formatted date/time (not a byte-identical `strftime("%c")` reproduction) — cosmetic only | `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`; `strftime`/`localtime` CRT calls at `@0x004a322c`/`@0x004a3216` | --- -## 2. Adaptation (AD) — 82 active rows (AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 83 active rows (AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -106,6 +107,7 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-108 | **Filed 2026-08-17 at the night-round review fix round (F9); MECHANISM REPLACED the same day at the overnight round's final fix, after live verification found the row's original standalone re-import resolving NOTHING.** Retail authors the Map tab's player-location and house-location icons (`0x100001ED`/`0x100001EE`) as ordinary nested dat children of `m_pMap` (`0x100001EC`) — itself a Type-1 `UIElement_Button`, the GM click-to-teleport feature `gmMapUI::ListenToElementMessage @0x004a2350` idMessage `0x1c` reads — and `gmMapUI::PostInit @0x004a1c70` resolves them as ordinary live child elements. acdream's `UiButton.ConsumesDatChildren` swallows a button's dat children as skin/label parts during the normal import walk, so the two icons never exist in the built tree and `UiElement.FindDescendant` against the page root returns null for them. **The shipped adaptation:** `MapPageController.Bind` finds each icon's `ElementInfo` under `m_pMap`'s own ALREADY-RESOLVED info subtree — `pageInfo`, a subtree of the panel-slot resolve `ImportInfos(dats, 0x2100006E, 0x1000018C)`, the ONLY pathway that materializes these infos at all — and BUILDS it through the new `Bindings.IconBuilder` seam (production: `LayoutImporter.Build(info, ...)` under the DAT lock — the build half of `RowTemplateResolver`'s shape, no import half), attaching the result as a runtime child of the built `m_pMap`. Live-DAT-pinned structural facts (`MapHousePanelLiveDatMountTests`, the pin the original gap proved missing): a cold `ImportInfos(dats, hostLayoutId, iconElementId)` returns null for BOTH icons — its `FindDesc` walks the LayoutDesc's raw top-level `Elements` table (exactly ONE entry for host layout `0x2100006E`) recursing through `ElementDesc.Children`, a purely structural walk with no tab-page/state-descriptor resolution — while the full panel-slot resolve materializes both icons nested under `m_pMap` with real authored extents. (The town-hotspot template `0x100001F0` is different in kind: a genuine standalone catalog entry addressable by `(templateLayoutId, templateElementId)`, whose import-then-build resolution is correct and unchanged.) `ResolveSwallowedIcon` also prefers an icon the normal build walk DID produce (`FindDescendant` under `m_pMap` first), so a future `ConsumesDatChildren` policy change cannot leave a second, permanently-static copy behind the live marker. **Second mechanism half (found by this fix's own F1 live verification):** both icons are detached from the per-frame authored layout pass (`PrepareIcon` sets `Anchors = AnchorEdges.None`, which also clears any imported `LayoutPolicy`) because `PlaceMarker` owns their position outright (retail's `gmMapUI::Update` re-places both markers every tick, and retail's `UpdateForParentSizeChange` runs only on actual parent resize) — acdream re-runs `ApplyAnchor` per frame, and the icon's compatibility anchor had captured the authored `(0,0)` rect while the panel window was still hidden, re-asserting it every frame over PlaceMarker's writes: a live-observed visible green ring pinned to `m_pMap`'s top-left corner regardless of player position, with only the coordinate text correct. | `src/AcDream.App/UI/Layout/MapPageController.cs` (`Bind`'s two `ResolveSwallowedIcon` call sites, `ResolveSwallowedIcon`'s body, `Bindings.IconBuilder`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountMapHousePanel`'s `BuildSwallowedIcon`) | The rebuilt icons carry their authored ids and extents — `PlaceMarkerOnMap`'s centering divides the icon's own Width/Height, and the pin test asserts non-degenerate extents on the installed DAT. Their authored local position is irrelevant: `PlaceMarker` overwrites `Left`/`Top` on every 5 s `Refresh`, and each icon starts hidden until the first refresh decides real visibility — same net presentation as retail's find-the-child. | A future DAT regeneration that reauthors the icons OUTSIDE `m_pMap`'s subtree would leave `FindInfo(mapInfo, iconId)` null again — the same silent "[D.2b] … not authored under m_pMap" log-and-hide failure mode this row's original defect had, but now caught by `MapHousePanelLiveDatMountTests` failing on the next suite run instead of only at a connected gate. | `gmMapUI::PostInit @0x004a1c70` (child resolution); `gmMapUI::ListenToElementMessage @0x004a2350` idMessage `0x1c` (confirms `m_pMap` IS a button, not a passive container); `gmMapUI::PlaceMarkerOnMap @0x004a18b0` (the marker math consuming the rebuilt icons) | | AD-106 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system).** Retail's tooltip popup is a separate always-on-top presentation surface — `UIElementManager::StartTooltip @0x00459700` positions and latches it into `m_pTooltipElement`, drawn independently of the ordinary `UIElement` sibling tree (the SAME class of separation the AP-229 register row already establishes for retail's dialogs vs acdream's flat sibling list under one `Host.Root`). `RetailTooltipPresenter` instead mounts the popup as an ordinary `UiRoot` child sibling (`_host.AddChild(root)`) and keeps it topmost by calling `BringToFront` from its OWN `Tick()`, which `RetailUiRuntime.Tick` schedules AFTER both `RetailDialogFactory.Tick()` and `Host.Tick()` in the same frame — guaranteeing the tooltip wins whatever z-order race those two just ran, every frame, regardless of which dialog/screen last called its own `BringToFront`. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`Tick`, `OnTooltipShow`'s `AddChild`/`BringToFront`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Tick`'s three-call ordering, `MountTooltipPresenter`) | Reproduces the one observable invariant a user can check (tooltips always draw on top of dialogs and screens) without porting retail's literal separate-layer architecture (no second draw pass, no dedicated presentation root) — the SAME tradeoff AP-229 already accepted for dialogs, extended one layer further. The ordering is enforced structurally (three sequential calls in one method), not by convention, so it cannot silently regress from an unrelated edit reordering unrelated `Tick` calls elsewhere. **F10 correction (2026-08-16 review round), two honest additions:** (1) the guarantee is versus dialogs/screens ONLY — `UiRoot.DrawCore`'s own second pass (`ctx.BeginOverlayLayer(); DrawOverlays(ctx); DrawDragGhost(ctx);`) routes open dropdown/menu popups and the drag ghost to a renderer overlay layer that paints over the WHOLE sibling tree unconditionally, so both still paint above a shown tooltip regardless of any `BringToFront` ordering — no z-order fix in the sibling tree can reach that layer. (2) counting the full chain by its own actual participants (not just the three calls local to `RetailUiRuntime.Tick`'s tooltip-adjacent lines), the per-tick `BringToFront` ratchet has FOUR rungs in frame order: `CharacterManagementUiController.Tick`, `CharacterCreationUiController.Tick` (both named in `RetailDialogFactory`'s own GF-15 doc comment as the screens it re-asserts over), `RetailDialogFactory.Tick`, then `RetailTooltipPresenter.Tick`. Four independent per-tick self-reraises stacked by tick ORDER is a design smell — a correct z-order model would need at most one authoritative comparison, not N racing assertions — but is bounded and enumerable in practice (no unbounded surface list, the order is fixed source, not runtime-discovered) so it is left as observed rather than restructured this round. | A FUTURE always-on-top UI surface that calls its own unconditional per-tick `BringToFront` AFTER `TooltipPresenter?.Tick()` in `RetailUiRuntime.Tick`'s ordering could bury a currently-shown tooltip — the exact failure class AP-229 already named for dialogs-vs-screens, now with four layers instead of two. | `UIElementManager::StartTooltip @0x00459700` (`m_pTooltipElement` ownership); AP-229's own dialog/screen precedent | | AD-73 | Filed 2026-08-11 at the Campaign OP OP2 rework (fix round after a double REJECT). `UiTabPanel` (dat Type 8, formerly `UiTabControl`) does NOT perform retail's automatic tab-table wiring / default-page activation at construction. Retail `UIElement_Panel::SetupTabPageHash @0x0046C2E0` + `::Update @0x0046BD00` unconditionally activate the authored default page for ANY instance that carries a tab table. `UiTabPanel` instead stays DORMANT — no click binding, no page-visibility flip, no tab Open/Closed write — until a controller explicitly calls `ActivateTabBehavior()`. | `src/AcDream.App/UI/UiTabPanel.cs` (`ActivateTabBehavior`); factory site `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (Type-8 arm) | Four already-shipped Type-8 hosts author a tab table today — character sheet root `0x10000227`, spellbook root `0x100002A8`, and vendor `0x100000B8` already implement this exact switching in their own C# controllers (`CharacterStatController`/`SpellbookWindowController`/`VendorUiController`); activating `UiTabPanel`'s own copy unconditionally would double-drive the same page-visibility/tab-state writes those controllers already own. Combat `0x100000A2` has no controller at all and is INTENTIONALLY left inert (its 8 stance pages have no switching UI yet) rather than have `UiTabPanel` silently take ownership. Only newly-authored hosts opt in (Options panel, Campaign OP slice OP3+; Configure Keyboard, OP8). This is what let the unconditional Type-8 factory mapping become safe after the OP2 REJECT (`docs/research/2026-08-11-op2-review-blast.md`, `docs/research/2026-08-11-op2-review-mechanism.md`). | A future panel that authors a Type-8 tab table but never gets a controller call to `ActivateTabBehavior()` renders with every tab button at its authored default (Closed) and every page slot at its default `Visible=true` — i.e. every page overlapping, no single active page — instead of retail's exactly-one-visible-page behavior. This is silent unless the diagnostic `UnresolvedEntries`/`BehaviorActive` surface is checked; a controller author who forgets the activation call will see a visually broken tab host, not a crash. | `UIElement_Panel::SetupTabPageHash @0x0046C2E0`; `UIElement_Panel::Update @0x0046BD00`; `UIElement_Panel::OpenTab @0x0046BE20`. ADDENDUM (2026-08-11, re-review closure): `UiTemplateListBox` additionally reports `ConsumesDatChildren = true` where the pre-rework fallback did not — inert against every shipped layout because no Type-5 element in any of the 32 fixtures authors children (now conformance-PINNED in `OP2ReworkBlastRadiusConformanceTests`, so an authored child appearing in a future DAT regeneration fails the build instead of silently vanishing) | | ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) | @@ -410,7 +412,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. The 15 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its edge-fired (no dwell), `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. STILL NO ACDREAM ANALOG — the spellcasting endowment icon `@0x004C63A1` / cast button `@0x004C6FE8` / favorite `@0x004C7206` / submenu `@0x004C67D8` (all `UiCatalogSlot`-based, which already has its own independent `Label`-driven `GetTooltipText` — a real gap only if that Label wiring turns out incomplete, unaudited this round), the map notes `gmMapUI::AddMapNote @0x004A1C51` (no acdream map UI), and the character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors. Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`) | +| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its edge-fired (no dwell), `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification) plus a hardcoded `AuthoredTooltipRootElementId`/`AuthoredTooltipLayoutDid` pointing at the same shared popup skin `UiItemSlot` already uses (`0x10000395`/`0x21000041`), since the town-marker template authors no locator of its own; verified live post-fix (hovering Aerlinthe Island renders its tooltip correctly) — literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — 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, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | | TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | diff --git a/docs/research/2026-08-17-map-house-recon.md b/docs/research/2026-08-17-map-house-recon.md new file mode 100644 index 00000000..008dfca3 --- /dev/null +++ b/docs/research/2026-08-17-map-house-recon.md @@ -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`. `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. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 90bbc822..ae49bfc2 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -79,7 +79,15 @@ internal sealed record InteractionRetainedUiDependencies( Func ClientTime, Action Log, 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 CurrentCalendar) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -970,6 +978,24 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory AllegianceSetUpdateSubscription: on => late.GameRuntime.AllegianceSetUpdateSubscription(on), 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, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 6af15653..cecf9923 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -327,14 +327,28 @@ internal sealed class LiveSessionRuntimeFactory (text, type) => _domain.Communication.AddText(text, type), Fellowship: _domain.Runtime.FellowshipOwner, Allegiance: _domain.Runtime.AllegianceOwner, - Trade: _domain.Runtime.TradeOwner)); + Trade: _domain.Runtime.TradeOwner, + House: _domain.Runtime.HouseOwner)); return new GraphicalSessionEventRoute( route, _domain.Runtime, _world.PlacementProjection, _world.PlacementRetries, _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.RemotePlacementDrive); } diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs index f1238af6..70dc47ef 100644 --- a/src/AcDream.App/Net/RetailSkillFormula.cs +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -85,6 +85,128 @@ internal static class RetailSkillFormula _ => result, }; } + + /// + /// Retail SkillSystem::InqAttributeName @ 0x005c8d90 — the six + /// hardcoded attribute display names (matched exactly against + /// DatReaderWriter.Enums.AttributeId's Strength=1..Self=6 + /// numbering, the same table -style + /// switches elsewhere in this file already assume). + /// + 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, + }; + + /// + /// Retail SkillSystem::InqSkillFormula @ 0x005c89b0 — builds the + /// human-readable formula line shown in a skill's tooltip, e.g. + /// "( (Strength + Coordination) / 2 )", "( Quickness )", or + /// "( (2 x Quickness) )". Ported byte-for-byte from the retail + /// binary's string pool: the five short literal fragments below + /// (data_7e7930 = " )", data_7e7934 = "+%u", + /// data_7e7940 = " + ", data_7e7950 = "(", + /// data_7e7954 = "( ", data_797584 = ")") + /// 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 AC1Legacy::PStringBase<char>, so 1 + /// byte/char, not the 2-byte/char wide encoding used elsewhere in this + /// file's neighborhood). " / %u" (divisor) and "(%u x %s)" + /// (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 (_x < 1 || _attr1 == 0 AND the + /// attr2 equivalent), matching InqSkillFormula's own false + /// return. + /// + 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(); + } + + /// + /// Retail SkillInfoRegion::GetTooltip @ 0x004f1fe0, called once + /// from SkillInfoRegion::SkillInfoRegion @ 0x004f2140's + /// UIElement::SetTooltip at 0x004f222f. Composition is exactly + /// "\n" + formula + description — retail concatenates the + /// description directly onto the formula line with NO separator between + /// them (ported verbatim, not "fixed": append_n_chars runs + /// immediately after the formula assignment with no intervening + /// literal). SkillSystem::InqSkillDescription @ 0x005c8770 reads + /// SkillBase._description — the same DAT field + /// already + /// exposes, so no hand-transcription was needed for the ~30+ skill + /// description strings (unlike the six hardcoded attribute + /// descriptions). + /// + 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; + } } /// diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 50cc1d79..1be03727 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1419,7 +1419,8 @@ public sealed class GameWindow : ClientTimerNow, Console.WriteLine, hostInputCamera.GpuDevice, - hostInputCamera.GpuFrameLifetime), + hostInputCamera.GpuFrameLifetime, + () => WorldTime.CurrentCalendar), _retailUiLease, this).Compose( platformResult, diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index fd7ae395..f0d0f1f3 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -228,4 +228,11 @@ public sealed record CharacterSkill( // retail SkillInfoRegion::GetVitaeModifier (0x004f0fa0). Used for the // footer-title vitae-specific parenthetical, separate from the buff delta // (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); diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 69cddd55..dc2ecb2a 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using AcDream.App.Net; using AcDream.Core.Items; using AcDream.Core.Player; using DatReaderWriter; @@ -382,6 +383,10 @@ public sealed class CharacterSheetProvider int specializedCost = skillBase?.SpecializedCost ?? 0; long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1); 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 — // retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier @@ -406,7 +411,8 @@ public sealed class CharacterSheetProvider specializedCost, raiseCost, raise10Cost, - values.VitaeModifier)); + values.VitaeModifier, + tooltipText)); } return result; diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 63c250d2..bbda95dc 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -192,6 +192,43 @@ public static class CharacterStatController ("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6 }; + /// + /// TS-85 (character-panel tooltips): retail SkillSystem::InqAttributeDescription + /// @ 0x005c8e30 — six hardcoded strings, byte-decoded from the retail binary's + /// string pool (the pseudo-C dump truncates them with "…"). Ported from + /// AttributeInfoRegion::AttributeInfoRegion @ 0x004f1530's + /// UIElement::SetTooltip call at 0x004f1617, keyed by retail attribute id + /// (matches ' statId column, NOT the array index — the + /// authored row order swaps Coordination/Quickness relative to the id numbering). + /// + private static readonly IReadOnlyDictionary AttributeDescriptions = + new Dictionary + { + [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 + }; + + /// + /// TS-85 (character-panel tooltips): retail SkillSystem::InqAttribute2ndDescription + /// @ 0x005c8f70 — three hardcoded strings shared by each Max/Current pair (1&2, + /// 3&4, 5&6), byte-decoded from the retail string pool. Ported from + /// Attribute2ndInfoRegion::Attribute2ndInfoRegion @ 0x004f1680's + /// UIElement::SetTooltip call at 0x004f1777, keyed by ' + /// maxStatId column (1/3/5 — either member of the pair resolves the same text in + /// retail). + /// + private static readonly IReadOnlyDictionary Attribute2ndDescriptions = + new Dictionary + { + [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 + }; + /// /// Bind the Attributes-tab header + 9-row list + footer elements, tab button states, /// and raise buttons in to . @@ -671,7 +708,7 @@ public static class CharacterStatController for (int i = 0; i < AttrRows.Length; i++) { - var (rowName, iconDid, _) = AttrRows[i]; + var (rowName, iconDid, statId) = AttrRows[i]; int rowIndex = i; var row = AddRow(list, datFont, spriteResolve, @@ -694,6 +731,7 @@ public static class CharacterStatController return v.ToString(); }, valueColorProvider: () => AttributeValueColor(data(), rowIndex)); + row.TooltipText = AttributeDescriptions.GetValueOrDefault(statId); row.OnClick = () => { @@ -706,7 +744,7 @@ public static class CharacterStatController for (int i = 0; i < VitalRows.Length; i++) { - var (rowName, iconDid, _) = VitalRows[i]; + var (rowName, iconDid, maxStatId) = VitalRows[i]; int rowIndex = i; int absIndex = AttrRows.Length + i; @@ -726,6 +764,7 @@ public static class CharacterStatController }; }, valueColorProvider: () => VitalValueColor(data(), rowIndex)); + row.TooltipText = Attribute2ndDescriptions.GetValueOrDefault(maxStatId); row.OnClick = () => { @@ -785,6 +824,9 @@ public static class CharacterStatController valueProvider: () => LiveSkill().CurrentLevel.ToString(), valueColorProvider: () => SkillValueColor(LiveSkill()), 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 = () => { HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10); diff --git a/src/AcDream.App/UI/Layout/HousePageController.cs b/src/AcDream.App/UI/Layout/HousePageController.cs new file mode 100644 index 00000000..5ea2613f --- /dev/null +++ b/src/AcDream.App/UI/Layout/HousePageController.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the House tab of the retail Map/House panel (gmHouseUI, +/// class id 0x10000025) — 's +/// second page. +/// +/// +/// Retail references: gmHouseUI::PostInit @0x004a2710 resolves ONE +/// UIElement_ListBox (m_pTextBox = 0x100001e6) and registers +/// four notice handlers for wire opcodes 0x0225-0x0228. +/// gmHouseUI::AddHousePanelText @0x004a2810 is +/// UIElement_ListBox::AddItemFromTemplateList(this, 0, nullptr) — +/// live-DAT-confirmed (MapHousePanelSlotProbeTests) as a single +/// UIElement_Text (Type 12) row template at LayoutDesc +/// 0x21000025 element 0x100001e7, 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 +/// PostInit never calls Update/DisplayHouseData). +/// +/// +/// +/// Scope — see ISSUES #413 for the full ledger. Batch C +/// (2026-08-17) shipped the mount (this class) and the wire PARSING +/// groundwork (GameEvents.ParseHouseData/ParseHouseStatus/ +/// ParseUpdateRentTime/ParseUpdateRentPayment, +/// GameEventWiring's four delegate holes, the outbound HouseQuery +/// action). The House-tab ownership-text closer session (also 2026-08-17) +/// wired to the minimal RuntimeHouseState +/// owner and ported DisplayPurchaseTimeText @0x004a3110'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 +Acdream character on a local ACE server). The +/// other six Display* line builders DisplayHouseData 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 +/// 's own doc), so by the time a player opens +/// the House tab the data has usually already arrived. +/// +/// +public sealed class HousePageController +{ + public const uint TextBoxId = 0x100001E6u; + + public sealed record Bindings( + Func> 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? TemplateResolver = null); + + private readonly UiTemplateListBox _listBox; + private readonly Bindings _bindings; + private IReadOnlyList _lastLines = Array.Empty(); + + 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; + } + + /// 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). + public void Tick() + { + IReadOnlyList lines = _bindings.Lines(); + if (lines.SequenceEqual(_lastLines)) return; + Refresh(lines); + } + + public void OnShown() => _bindings.OnShown?.Invoke(); + + private void Refresh(IReadOnlyList 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)]; + } + } +} diff --git a/src/AcDream.App/UI/Layout/MapHousePanelController.cs b/src/AcDream.App/UI/Layout/MapHousePanelController.cs new file mode 100644 index 00000000..b8b5c943 --- /dev/null +++ b/src/AcDream.App/UI/Layout/MapHousePanelController.cs @@ -0,0 +1,185 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Mounts retail's two-tab Map/House panel — LayoutDesc 0x2100006E +/// slot 0x1000018C, +/// id 16. Batch C (overnight hover/UI round, 2026-08-17), built on +/// the / +/// recipe (Type-8 tab host, , +/// per-page scoped controllers). +/// +/// +/// Slot/panelId/button — resolved, not guessed. The FA campaign's own +/// full 16-slot gmPanelUI::SetupChildren dump +/// (docs/research/2026-08-11-fa-panel-structure.md:927-933) already +/// named slot 0x1000018C as "gmMapUI+gmHouseUI pages", panel id 16 — +/// this session's MapHousePanelSlotProbeTests re-confirmed it live. +/// The toolbar button is 0x1000019A (own authored +/// P0x10000029 = 16), one of three currently-ghosted panel buttons +/// (see docs/research/2026-08-17-map-house-recon.md). +/// +/// +/// +/// Tab table — live-DAT-confirmed. +/// button 0x100001F3 -> page 0x100001F6 (Map, DEFAULT), +/// button 0x100001F4 -> page 0x100001F7 (House). The page roots are +/// themselves typed 0x10000026 (gmMapUI) / 0x10000025 +/// (gmHouseUI) in the DAT — has no special +/// case for either id, so they build as generic containers (same as every +/// other unmodeled retail UI class); and +/// find their own signature children by +/// id underneath. +/// +/// +/// +/// Close button: 0x100001F5 (Type 1, top-right at +/// (276,0) 24x25) — same authored position/size as the Social +/// panel's own close button, the established gmPanelUI sibling +/// convention. +/// +/// +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 _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; + } + + /// The pre-Build tree + /// was built from — + /// needs it to read m_pMap's own authored int/enum attrs, which + /// only exist on , not the built + /// tree. + 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(); + } + + /// 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. + 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; + } +} diff --git a/src/AcDream.App/UI/Layout/MapLocations.cs b/src/AcDream.App/UI/Layout/MapLocations.cs new file mode 100644 index 00000000..47198cf0 --- /dev/null +++ b/src/AcDream.App/UI/Layout/MapLocations.cs @@ -0,0 +1,79 @@ +namespace AcDream.App.UI.Layout; + +/// +/// One retail map hotspot rect + rollover name. Mirrors +/// gmMapUI::LocationRolloverInfo (acclient.h:55686): X/Y/Width/ +/// Height are direct pixel offsets within m_pMap (no coordinate +/// transform — gmMapUI::AddMapNote @0x004a1bb0 passes them straight to +/// MoveTo/ResizeTo), and Name is the literal tooltip text +/// (StringInfo::SetLiteralValue, not a DAT string-table lookup). +/// +public readonly record struct MapLocation(int X, int Y, int Width, int Height, string Name); + +/// +/// Verbatim port of retail's static s_rgLocations[0x35] table +/// (docs/research/named-retail/acclient_2013_pseudo_c.txt:977225-977651), +/// consumed by gmMapUI::PostInit @0x004a1c70 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. +/// +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"), + }; +} diff --git a/src/AcDream.App/UI/Layout/MapPageController.cs b/src/AcDream.App/UI/Layout/MapPageController.cs new file mode 100644 index 00000000..07eaea6d --- /dev/null +++ b/src/AcDream.App/UI/Layout/MapPageController.cs @@ -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; + +/// +/// Binds the Map tab of the retail Map/House panel (gmMapUI, class id +/// 0x10000026) — 's default page. +/// +/// +/// Retail references: gmMapUI::PostInit @0x004a1c70 (child +/// resolution + hotspot template setup), gmMapUI::Update @0x004a1eb0 +/// (5 s refresh cadence — date/time text, coordinate readout, both +/// markers), gmMapUI::PlaceMarkerOnMap @0x004a18b0 (marker centering +/// math), gmMapUI::AddMapNote @0x004a1bb0 (town hotspot +/// instantiation + literal-string tooltip). Live-DAT byte values confirmed +/// by MapHousePanelSlotProbeTests: marker area +/// (6,8)-(247,258), hotspot template element 0x100001F0 in +/// LayoutDesc 0x21000026. +/// +/// +/// +/// Coordinate math reuses (already a byte- +/// exact port of the same CPlayerSystem::InqPlayerCoords @0x00560090 +/// formula the radar's own coordinate strip uses) rather than re-deriving +/// it — see the recon doc's "no re-port needed" note. +/// +/// +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; + + /// Retail's own 5 s tick cadence (gmMapUI::Update's + /// m_nextUpdate = Timer::cur_time + 5.0). + public const double RefreshIntervalSeconds = 5.0; + + public sealed record Bindings( + Func CurrentCalendar, + Func 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 HousePosition, + Func 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 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; + } + + /// + /// Binds an already-built page root ( + /// resolves the page via the panel's tab table). Reads m_pMap's + /// own marker-area rect straight from the ORIGINAL + /// (post-Build widgets don't carry authored int attrs), instantiates the + /// 53 town hotspots once, and returns a controller ready for + /// per-frame polling. + /// + /// + /// Live-DAT structural finding (MapHousePanelSlotProbeTests' follow-up + /// dump): m_pMap (0x100001EC) is itself authored as a + /// Type-1 BUTTON (the GM click-to-teleport feature at + /// gmMapUI::ListenToElementMessage @0x004a2350 idMessage + /// 0x1c), and the player/house icons (0x100001ED/ + /// 0x100001EE) are authored as ITS OWN nested children, not + /// siblings. swallows a + /// button's dat children as skin/label parts, so they never appear in + /// the normally-built tree — + /// against the page root always returns null for them. Their + /// s, however, DO survive: + /// is a subtree of the panel's full slot resolve + /// (ImportInfos(0x2100006E, 0x1000018C)), the only pathway that + /// materializes them at all — a cold + /// ImportInfos(hostLayoutId, iconElementId) starting from the + /// icon id returns null on the live DAT because the raw LayoutDesc + /// Elements-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 m_pMap's own already-resolved info and + /// BUILDING each via , then attached + /// under m_pMap directly — their authored local position is + /// irrelevant since overwrites it every + /// refresh. + /// + /// + 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; + } + + /// Resolves one of m_pMap's button-swallowed nested + /// icon children by finding its under + /// — the panel-slot resolve tree, the ONLY + /// place these infos exist (see 's own doc + register + /// row AD-108) — building it via , and + /// attaching it under . Starts hidden — the first + /// call (from ) decides real + /// visibility. + private static UiElement? ResolveSwallowedIcon( + UiElement map, ElementInfo? mapInfo, Func 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; + } + + /// Marks one marker icon as runtime-positioned. + /// owns the element's position outright (retail's gmMapUI::Update + /// 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 , which the + /// 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 decides real visibility. + private static UiElement PrepareIcon(UiElement icon) + { + icon.Anchors = AnchorEdges.None; + icon.Visible = false; + return icon; + } + + private static IReadOnlyList ToLines(string? text, System.Numerics.Vector4 color) + { + if (string.IsNullOrEmpty(text)) return Array.Empty(); + 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; + } + + /// + /// Instantiates the 53 static town hotspots (gmMapUI::AddMapNote) + /// from m_pMap's own 0x47/0x48 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 if (eax_10 != 0) before the loop. + /// + private void BuildTownMarkers(ElementInfo? mapInfo, Func 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); + } + } + + /// Per-frame poll, accumulating wall-clock deltas + /// ('s own shape) into retail's 5 s + /// cadence — same net effect as Timer::cur_time comparison + /// without needing a separate absolute clock dependency. + 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; + } + + /// + /// "Date: %s\nTime: %s" (gmMapUI::Update's sprintf shape, + /// fed by GameTime::GetDateTimeString @0x005a6530). Month names + /// already match retail display text 1:1 + /// (); hour names need the + /// "AndHalf" suffix rewritten to "-and-Half". + /// + 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; + } + + /// + /// gmMapUI::Update @0x004a2078's gate is + /// if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0) + /// — BOTH widgets present, not "at least one". Night-round review F15: + /// the prior _coordinateText is null && _playerIcon is null + /// 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. + /// + 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; + } + } + + /// + /// gmMapUI::Update @0x004a22a6-f6: Position::get_outside_cell_id + /// (&m_HousePosition) -> LandDefs::gid_to_lcoord -> the SAME + /// (v-0x400)*0.1+0.5 transform 's player + /// branch uses. + /// + /// + /// Night-round review F14: this passes housePosition.Value.LandblockId + /// straight to , SKIPPING the + /// Position::get_outside_cell_id @0x004527b0 step retail's own + /// call chain names. That function is itself BN-mangled (its final + /// return ((eax_2 - eax_2) & objcell_id) — 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 LandDefs::adjust_to_outside, + /// 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): is wired + /// () => null 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 Position::get_outside_cell_id / + /// LandDefs::adjust_to_outside (byte-decode required, + /// @0x004527b0 / call site @0x004a2297) 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 adjust_to_outside 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 correctly refuses + /// any cell with an envcell low word). + /// + 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); + } + + /// + /// gmMapUI::PlaceMarkerOnMap @0x004a18b0, ported from a direct + /// byte-read of the PDB-paired acclient.exe (Binary Ninja elides + /// the whole FPU chain to bare, operand-less _ftol2() calls — + /// see docs/research/named-retail/acclient_2013_pseudo_c.txt + /// 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 (/, range + /// approximately ±102.4) onto the marker-area rect's pixel span via a + /// fixed-point-style transform, not a raw pixel add: + /// + /// 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) ) + /// + /// Constants read straight from the binary's .rdata: 0x79bac8 = + /// 10.0, 0x7aac78 = 1024.0, 0x7aac70 = -1/2048 (exactly + /// -0.00048828125), 0x7aac68 = 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. + /// w/h are the icon's own UIRegion::GetWidth/ + /// GetHeight (@0x0069efe0/@0x0069eff0), halved with INTEGER + /// (truncating) division to match retail's cdq;sub;sar 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. + /// + 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; + } + + /// + /// The pure PlaceMarkerOnMap math, split out from + /// so tests can assert byte-decoded GOLDEN PIXEL values directly against + /// the formula instead of round-tripping through the port's own output. + /// + 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; + } +} diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index 0ceab77f..5d2360cf 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -186,9 +186,34 @@ public sealed class RetailTooltipPresenter : IDisposable /// family resolves to). Extracted unchanged from the pre-#411-follow-on /// OnTooltipShow body — same F4/F5/F8 fixes, same failure /// handling. + /// + /// + /// Night-round review F5: the single-popup invariant (retail's own + /// single m_pTooltipElement 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: 's + /// own clear is gated on _worldTooltipShowing (only true when the + /// WORLD path itself mounted the current popup) and its "a UI popup + /// cannot be showing here" comment assumed '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 stays mounted + /// (_owner/_popupRoot set, _worldTooltipShowing + /// 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. + /// /// private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText) { + RemovePopup(); + ImportedLayout? layout; try { @@ -295,17 +320,39 @@ public sealed class RetailTooltipPresenter : IDisposable // directly by gmGamePlayUI's own mode setup rather than from a // walkable authored ElementDesc, so its own P0x47/P0x48 cannot be // read from the DAT. This port therefore REUSES the item catalog's - // confirmed uniform popup-locator pair (WorldPopupRootElementId/ - // WorldPopupLayoutDid below) — the SAME "generic runtime-text" skin - // every other game-code SetTooltip caller in this family draws from — - // as the best-evidenced inference for the unrecoverable constant. + // confirmed uniform popup-locator pair (SharedPopupSkinRootElementId/ + // SharedPopupSkinLayoutDid below) — the SAME "generic runtime-text" + // skin every other game-code SetTooltip caller in this family draws + // from — as the best-evidenced inference for the unrecoverable + // constant. - /// Same popup skin every UIItem prototype resolves to - /// ('s own ItemTooltipRootElementId) — - /// see this section's own doc note on why the exact value cannot be - /// read off an authored UIElement_SmartBoxWrapper ElementDesc. - private const uint WorldPopupRootElementId = 0x10000395u; - private const uint WorldPopupLayoutDid = 0x21000041u; + /// + /// The shared popup-skin locator pair every tooltip-bearing surface + /// that authors no locator of its own resolves to. Retail's shared + /// UIItem cell-template catalog (ItemListCellTemplate.CatalogLayoutId, + /// LayoutDesc 0x21000041) authors the SAME + /// P0x47=0x10000395/P0x48=0x21000041 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 — + /// TooltipLiveDatTests.PopupSkinRootIds/ + /// UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator) — + /// one of the four 30x30 popup skins this presenter mounts for every + /// authored tooltip-bearing element too. + /// + /// + /// Night-round review F10: previously duplicated as three separate + /// private constants with three separate partial citations — this + /// class's own world-hover popup (below), UiItemSlot's item-cell + /// popup, and MapPageController'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 + /// (OnTooltipShow/UpdateWorldHoverTooltip both call + /// TryBuildAndMountPopup with these values or a widget's own). + /// + public const uint SharedPopupSkinRootElementId = 0x10000395u; + public const uint SharedPopupSkinLayoutDid = 0x21000041u; private uint _worldHoverGuid; private bool _worldTooltipShowing; @@ -353,12 +400,23 @@ public sealed class RetailTooltipPresenter : IDisposable return; // no change -> RecvNotice_SmartBoxObjectFound never re-fires _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 (_worldTooltipShowing) - RemovePopup(); return; - } if (WorldTooltipsEnabled?.Invoke() != true) return; @@ -370,7 +428,7 @@ public sealed class RetailTooltipPresenter : IDisposable // A UI-element popup cannot be showing here: UiRoot's own hover // (queried above) is null whenever this branch runs, so its dwell // timer never arms and OnTooltipShow never fires concurrently. - if (TryBuildAndMountPopup(WorldPopupRootElementId, WorldPopupLayoutDid, text)) + if (TryBuildAndMountPopup(SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, text)) _worldTooltipShowing = true; } diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index d30366b5..2d4bc0ac 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -563,11 +563,145 @@ public sealed class SpellcastingUiController : IRetainedPanelController private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability(); + /// + /// 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). + /// private void UpdateCastAvailability() - => _cast.Enabled = _endowmentSelected[_activeTab] - ? _endowmentItemId != 0u - : _selected[_activeTab] is uint spellId - && _casting.IsTargetReady(spellId); + { + if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u) + { + (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"; + } + + /// + /// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30's endowment-item + /// branch (m_endowmentItemID != 0). Every literal below is directly + /// visible in the decomp (not the mislabeled-vtable-slot class the + /// plain-spell branch hits above): "USE the %s" @ data_7b64c0, + /// "You must select a target for the %s" @ data_7b6478, + /// " on %s" @ data_7b6464. ItemUses::IsUseable_SelfTarget @ + /// 0x004fcd30 is exactly + /// (both test the target-mask Self bit after shifting the high word down + /// 16). NOT ported: the incompatible-target sub-state ("You must select + /// an appropriate\ntarget for the %s" @ data_7b6400), which retail + /// derives from ItemHolder::TargetCompatibleWithObject @ 0x00587520 + /// — 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. + /// + 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}"); + } + + /// + /// Night-round review F4: the vararg to "USE the %s" (and both + /// select-target strings above) is NOT the bare item name — retail + /// builds "%s (%hs)" @0x7b64d8 (item name, spell name) once + /// at @0x004c6bb6-ef and reuses that composed string as the + /// shared operand for all three format strings (byte-confirmed: the + /// three sprintf call sites at 0x4c6c7f/0x4c6ca4/ + /// 0x4c6d46 all read the SAME [esp+0x18] slot). e.g. + /// "USE the Lightning Wand (Lightning Bolt VI)". + /// + private string ComposeEndowmentName(ClientObject endowment) + { + string itemName = endowment.GetAppropriateName(); + return _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata spellMetadata) + ? $"{itemName} ({spellMetadata.Name})" + : itemName; + } + + /// + /// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30's + /// plain-spell branch (m_endowmentItemID == 0, 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 sprintf calls were spurious. + /// Byte-confirmed pushes: "CAST %hs" @0x7b63a4 at both + /// 0x4c6f5d (untargeted/self-cast, always enabled) and + /// 0x4c6ea4 (targeted+compatible, enabled, then " on %s" + /// @0x7b6464 appended with the target's name at 0x4c6ee8); + /// "You must select an appropriate target for %hs" @0x7b6348 at + /// 0x4c6f18 (targeted+incompatible, stays disabled); "You + /// must select a target for %hs" @0x7b63b8 at 0x4c6e48 (no + /// target selected, stays disabled). %hs is the spell's own + /// name in every case (CSpellBase::InqName, the same call + /// (0x5bbee0) at all four sites) — no item/composed name + /// involved here, unlike the endowment branch above. + /// + 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() { diff --git a/src/AcDream.App/UI/RetailPanelCatalog.cs b/src/AcDream.App/UI/RetailPanelCatalog.cs index 4b8eee25..6b9d6e0b 100644 --- a/src/AcDream.App/UI/RetailPanelCatalog.cs +++ b/src/AcDream.App/UI/RetailPanelCatalog.cs @@ -42,6 +42,21 @@ public static class RetailPanelCatalog /// public const uint SocialPanel = 12u; + /// + /// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House + /// panel's gmPanelUI slot key — byte-verified from the live + /// installed DATs (host 0x2100006E slot 0x1000018C's own + /// authored 0x10000029 = 16, and the toolbar Map/House button + /// 0x1000019A's own authored 0x10000029 = 16, + /// MapHousePanelSlotProbeTests). Already independently named by + /// the FA campaign's full 16-slot dump + /// (docs/research/2026-08-11-fa-panel-structure.md:927-933, + /// "gmMapUI+gmHouseUI pages"). Unlike , a real + /// toolbar button opens this one — it is in BOTH + /// and . + /// + public const uint MapHouse = 16u; + private static readonly (uint PanelId, string WindowName)[] Mounted = { (CharacterInformation, WindowNames.CharacterInformation), @@ -55,6 +70,7 @@ public static class RetailPanelCatalog (Vitae, WindowNames.Vitae), (Options, WindowNames.Options), (SocialPanel, WindowNames.SocialPanel), + (MapHouse, WindowNames.MapHouse), }; private static readonly (uint PanelId, string WindowName)[] Toolbar = @@ -63,6 +79,7 @@ public static class RetailPanelCatalog (Character, WindowNames.Character), (Magic, WindowNames.Spellbook), (Options, WindowNames.Options), + (MapHouse, WindowNames.MapHouse), }; public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 64304dfd..96cc726b 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -289,6 +289,20 @@ public sealed record SocialRuntimeBindings( // Trailing/optional per the established compatibility convention. AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null); +/// +/// Batch C (overnight hover/UI round, 2026-08-17): bindings for the +/// two-tab Map/House panel. defaults to +/// "no house" and to empty when the caller doesn't +/// wire the House wire groundwork — the panel still mounts and the Map tab +/// still works standalone. +/// +public sealed record MapHouseRuntimeBindings( + Func CurrentCalendar, + Func PlayerCellId, + Func? HousePosition = null, + Func>? HouseLines = null, + Action? HouseShown = null); + public sealed record InventoryRuntimeBindings( ClientObjectTable Objects, Func PlayerGuid, @@ -451,6 +465,7 @@ public sealed record RetailUiRuntimeBindings( AppraisalRuntimeBindings Appraisal, OptionsRuntimeBindings Options, SocialRuntimeBindings Social, + MapHouseRuntimeBindings MapHouse, StackSplitQuantityState StackSplitQuantity, BufferedUiRegistry? Plugins, RetailUiPersistenceBindings? Persistence, @@ -542,6 +557,7 @@ public sealed class RetailUiRuntime : IDisposable MountDialogFactory(); MountTooltipPresenter(); MountSocialPanel(); + MountMapHousePanel(); MountCharacter(); MountPlugins(); MountInventory(); @@ -650,6 +666,7 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } + public MapHousePanelController? MapHousePanelController { get; private set; } internal CharacterManagementUiController? CharacterManagementController => _characterManagementMount?.Controller; internal CharacterCreationUiController? CharacterCreationController => @@ -822,6 +839,7 @@ public sealed class RetailUiRuntime : IDisposable SelectedObjectController?.Tick(deltaSeconds); ExternalContainerController?.Tick(); SocialPanelController?.Tick(); + MapHousePanelController?.Tick(deltaSeconds); _itemCooldownController?.Tick(); _characterManagementMount?.Tick(); CharacterManagementController?.Tick(); @@ -3256,6 +3274,141 @@ public sealed class RetailUiRuntime : IDisposable Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F)."); } + /// + /// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House + /// panel — host 0x2100006E slot 0x1000018C, + /// id 16. Same import/Build/Bind + /// recipe as . The House tab's ListBox rows + /// resolve through its own authored template directly via + /// (a FIXED + /// single template, unlike Friends/Squelch/Fellowship's live-roster + /// row families) — the below + /// serves only the Map tab's per-town hotspot template. + /// + 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()), + 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() { if (DialogFactory is not null) diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 8a06c830..6453dd39 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -19,25 +19,23 @@ public class UiItemSlot : UiElement /// live-DAT-probed 2026-08-16: every top-level catalog child (inventory's /// 32x32 cell 0x1000033A, the toolbar's per-slot prototypes /// 0x1000043B.., the container cell 0x1000033F, and every - /// paperdoll/armor slot skin alike) resolves P0x47=0x10000395 / - /// P0x48=0x21000041 through catalog inheritance, matching one of - /// the four popup skins already - /// mounts for every other tooltip-bearing element - /// (Layout.TooltipLiveDatTests.PopupSkinRootIds). Since - /// cells are built programmatically (never through - /// LayoutImporter.Build), this port hardcodes the uniform pair here - /// rather than re-deriving it per instance — the same "exhaustive scan, - /// then hardcode" shape as RetailCursorCatalog's five window-control - /// cursor DIDs and ItemListCellTemplate.CatalogLayoutId itself. + /// paperdoll/armor slot skin alike) resolves through catalog inheritance + /// to / + /// — + /// see that class for the single canonical citation (night-round review + /// F10 consolidated what used to be three separately-cited copies of the + /// same pair into one). Since cells are built + /// programmatically (never through LayoutImporter.Build), this + /// port hardcodes the uniform pair here rather than re-deriving it per + /// instance — the same "exhaustive scan, then hardcode" shape as + /// RetailCursorCatalog's five window-control cursor DIDs and + /// ItemListCellTemplate.CatalogLayoutId itself. /// - private const uint ItemTooltipRootElementId = 0x10000395u; - private const uint ItemTooltipLayoutDid = 0x21000041u; - public UiItemSlot() { ClickThrough = false; - AuthoredTooltipRootElementId = ItemTooltipRootElementId; - AuthoredTooltipLayoutDid = ItemTooltipLayoutDid; + AuthoredTooltipRootElementId = Layout.RetailTooltipPresenter.SharedPopupSkinRootElementId; + AuthoredTooltipLayoutDid = Layout.RetailTooltipPresenter.SharedPopupSkinLayoutDid; } public override bool ConsumesDatChildren => true; diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index 71bf0c8b..f74b1467 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -152,6 +152,20 @@ public class UiClickablePanel : UiPanel /// Ignored when is false. public float SelectionBarHeight { get; set; } = 3f; + /// Settable tooltip, surfaced through the shared + /// hover pipeline (same pattern as + /// / ). TS-85's + /// character-panel gap: retail's AttributeInfoRegion / + /// Attribute2ndInfoRegion / SkillInfoRegion row constructors + /// (UIElement::SetTooltip 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. + public string? TooltipText { get; set; } + + /// + public override string? GetTooltipText() => + string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + public UiClickablePanel() { // Rows must receive pointer events — override the UiPanel default (ClickThrough=false, diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs index f4dfaecc..f10241ff 100644 --- a/src/AcDream.App/UI/UiTemplateListBox.cs +++ b/src/AcDream.App/UI/UiTemplateListBox.cs @@ -177,6 +177,42 @@ public sealed class UiTemplateListBox : UiDatElement Height = Height, }; 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; } diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index 42da88bf..0f741703 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -34,4 +34,8 @@ public static class WindowNames /// Campaign FA slice FA3: the four-tab Friends/Allegiance/ /// Fellowship/Squelch panel (). public const string SocialPanel = "social-panel"; + + /// Batch C (overnight hover/UI round): the two-tab Map/House + /// panel (). + public const string MapHouse = "map-house"; } diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 395ec406..a2295608 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -124,7 +124,14 @@ public static class GameEventWiring Action? onTradeDecline = null, Action? onTradeReset = null, Action? 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? onHouseData = null, + Action? onHouseStatus = null, + Action? onHouseUpdateRentTime = null, + Action>? onHouseUpdateRentPayment = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -402,6 +409,43 @@ public static class GameEventWiring 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) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs index 6b50c3a2..2acd6b42 100644 --- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -58,6 +58,17 @@ public static class ClientCommandRequests public const uint AddPlayerPermissionOpcode = 0x0219u; public const uint RemovePlayerPermissionOpcode = 0x021Au; 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: // CM_Character::Event_TeleToMarketplace @ 0x006A1C20 @@ -282,6 +293,11 @@ public static class ClientCommandRequests public static byte[] BuildAbandonHouse(uint sequence) => 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) { byte[] body = new byte[12]; diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 23545771..4226fb0b 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -1003,6 +1003,126 @@ public static class GameEvents 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). + + /// One house purchase/maintenance line item. ACE + /// HousePaymentExtensions.Write: Num(int) + Paid(int) + WeenieID(uint) + + /// Name(String16L) + PluralName(String16L). + public readonly record struct HousePayment( + int Num, int Paid, uint WeenieID, string Name, string PluralName); + + /// 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 + /// already parses + /// elsewhere). + public readonly record struct HouseData( + uint BuyTime, + uint RentTime, + uint Type, + bool MaintenanceFree, + IReadOnlyList Buy, + IReadOnlyList Rent, + CreateObject.ServerPosition Position); + + public static HouseData? ParseHouseData(ReadOnlySpan 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? buy = ReadHousePaymentList(payload, ref pos); + if (buy is null) return null; + List? 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? ReadHousePaymentList(ReadOnlySpan payload, ref int pos) + { + if (payload.Length - pos < 4) return null; + uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + var list = new List((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; + } + + /// 0x0226 HouseStatus: a single WeenieError u32 — retail's + /// RecvNotice_FailedHouseTransaction family (also the "no house + /// owned" reply to a HouseQuery — ACE Player_House.cs + /// HandleActionQueryHouse's new GameEventHouseStatus(Session) + /// defaults to WeenieError.BadParam (corrected 2026-08-17; an + /// earlier note here said WeenieError.None, which is not what + /// GameEventHouseStatus's own constructor default reads). The + /// value is moot either way — decomp-confirmed retail's own + /// gmHouseUI::Update(uint32_t)/gmMapUI:: + /// RecvNotice_FailedHouseTransaction never read this field + /// (AcDream.Runtime.Gameplay.RuntimeHouseState.ApplyHouseStatus + /// accepts and discards it for the same reason). + public static uint? ParseHouseStatus(ReadOnlySpan payload) + { + if (payload.Length < 4) return null; + return BinaryPrimitives.ReadUInt32LittleEndian(payload); + } + + /// 0x0227 UpdateRentTime: a single uint (when the current + /// maintenance period began, Unix timestamp). ACE + /// GameEventHouseUpdateRentTime.cs is a STUB that always writes + /// 0u — captured here for completeness, not exercised by any + /// live ACE install today. + public static uint? ParseUpdateRentTime(ReadOnlySpan payload) + { + if (payload.Length < 4) return null; + return BinaryPrimitives.ReadUInt32LittleEndian(payload); + } + + /// 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. + public static IReadOnlyList? ParseUpdateRentPayment(ReadOnlySpan payload) + { + int pos = 0; + return ReadHousePaymentList(payload, ref pos); + } + // ── Shared string reader (matches LoginRequest.ReadString16L) ─────────── private static string ReadString16L(ReadOnlySpan source, ref int pos) diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index e135a6cf..357e9069 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2517,6 +2517,15 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildMansionRecall(seq)); } + /// Query the local player's house info — either owned house + /// data (0x0225) or a no-house status (0x0226) comes back + /// (0x021E). + public void SendHouseQuery() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildHouseQuery(seq)); + } + /// Query the local character's played time (0x01C2). public void SendQueryAge() { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 2b1ef090..16a4ed51 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -1266,6 +1266,14 @@ internal sealed class HeadlessSessionHost : IDisposable // RuntimeLiveEntitySessionController's onLoginCompleteSent // doc for the other two. _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); _eventRoute = eventRoute; diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index ecb985a8..f89374fd 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -109,6 +109,7 @@ internal enum GameRuntimeConstructionPoint FellowshipCreated, AllegianceCreated, TradeCreated, + HouseCreated, MovementCreated, ActionsCreated, EnvironmentCreated, @@ -127,6 +128,7 @@ internal sealed class GameRuntimeConstructionContext public RuntimeFellowshipState? Fellowship { get; set; } public RuntimeAllegianceState? Allegiance { get; set; } public RuntimeTradeState? Trade { get; set; } + public RuntimeHouseState? House { get; set; } public RuntimeLocalPlayerMovementState? Movement { get; set; } public RuntimeActionState? Actions { get; set; } public GameRuntimeEventHub? Events { get; set; } @@ -282,6 +284,17 @@ public sealed class GameRuntime context, 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(); // Campaign CH slice CH2: local jump refusals (CommenceJump/ // DoJump's WeenieError family — research doc §4.2/§6.4) reach @@ -337,7 +350,8 @@ public sealed class GameRuntime context.PlayerIdentity, context.Fellowship, context.Allegiance, - context.Trade); + context.Trade, + context.House); context.Movement.AttachPhysicsPublication( new RuntimeLocalPlayerPhysicsPublicationState( @@ -393,6 +407,7 @@ public sealed class GameRuntime FellowshipOwner = context.Fellowship; AllegianceOwner = context.Allegiance; TradeOwner = context.Trade; + HouseOwner = context.House; MovementOwner = context.Movement; ActionOwner = context.Actions; EnvironmentOwner = environment; @@ -501,6 +516,11 @@ public sealed class GameRuntime /// Secure trade (2026-08-14): third sibling J-owner. public RuntimeTradeState TradeOwner { get; } + + /// Batch C (2026-08-17): House tab minimal owner — see + /// 's own class doc for the sizing + /// rationale. + public RuntimeHouseState HouseOwner { get; } public RuntimeActionState ActionOwner { get; } public RuntimeLocalPlayerMovementState MovementOwner { get; } internal RuntimeLocalPlayerPhysicsPublicationState diff --git a/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs new file mode 100644 index 00000000..2c1ac073 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs @@ -0,0 +1,217 @@ +using System.Globalization; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using AcDream.Core.Properties; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Canonical presentation-independent owner for the House tab of retail's +/// two-tab Map/House panel (gmHouseUI). Deliberately MINIMAL — +/// "houseless-status only" per ISSUES #413's own sizing note: a full +/// RuntimeTradeState-weight owner (construction-transaction +/// Fault() 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. +/// +/// +/// +/// Retail behavior, exhaustively verified against the decomp before +/// writing this class (all seven line builders read, not just the one +/// ported here): gmHouseUI::PostInit @0x004a2710 never calls +/// Update/DisplayHouseData — the House ListBox +/// (0x100001e6) starts genuinely empty (live-DAT-confirmed, +/// MapHousePanelSlotProbeTests: children=0, and the whole +/// House page 0x100001F7 has NO other static content besides that +/// one empty ListBox). Content appears only after a server notice +/// (0x0225-0x0228) arrives and Update/DisplayHouseData runs +/// the seven Display* builders in order. SIX of them +/// (DisplayBuyPayment, DisplayRentPayment, +/// DisplayBuyTime, DisplayRentTimes, DisplayLocation, +/// DisplayWarningText) open with if (this->m_pHouseData != 0) +/// and emit NOTHING when houseless — those remain unported, ISSUES #413 +/// item 3. +/// +/// +/// The SEVENTH, gmHouseUI::DisplayPurchaseTimeText @0x004a3110, does +/// NOT gate on m_pHouseData — it always runs, reading the LOCAL +/// PLAYER's own PropertyInt.HousePurchaseTimestamp (0xC7 = 199 +/// decimal) via CBaseQualities::InqInt and +/// HouseSystem::HasPurchaseWaitPeriodExpired +/// (@0x005bb1d0: (Timer::get_real_time() - timestamp) > +/// 0x278d00; Timer::get_real_time = time(0), Unix epoch +/// seconds; 0x278d00 = 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 +/// m_pHouseData == 0 (still houseless) and emits the ONE literal +/// string at data_7ab7f0: "You may buy another house +/// immediately." 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 +/// data_7ab818, 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 ("You do not own a house!", +/// WeenieError 0x45E/0x45F, and "You must own a house to +/// use this command.", WeenieError 0x47F) routed through the +/// GENERIC WeenieError chat dispatcher, never through gmHouseUI's +/// own notice handlers (which discard the wire WeenieError entirely — see +/// ). +/// +/// +/// The NOT-yet-expired branch of DisplayPurchaseTimeText 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 +/// "You may buy another landscape house at " @0x7ab790 +/// (pushed @0x004a3265), the strftime format literal +/// "%c" @0x7ab7ec (pushed @0x004a321d) applied to +/// localtime(timestamp + 0x278d00) — the expiry moment, not "now" — +/// and suffix ". This restriction does not apply to apartments." +/// @0x7ab7b8 (pushed @0x004a3235). strftime's +/// "%c" is the C runtime's locale-default full date+time +/// representation; this port's honest analogue is .NET's own +/// culture-default DateTime.ToString() (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 +/// (docs/architecture/retail-divergence-register.md) records this +/// approximation. +/// +/// +public sealed class RuntimeHouseState +{ + /// HouseSystem::HasPurchaseWaitPeriodExpired's + /// literal, 0x278d00 = 2,592,000 seconds = 30 days. + 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 _lines = Array.Empty(); + + /// Borrows the canonical object table (optional for bare + /// fixtures) to read the local player's own + /// PropertyInt.HousePurchaseTimestamp — the same borrowed-owner + /// shape uses for its own object-table + /// read. + public RuntimeHouseState( + ClientObjectTable? objects = null, TimeProvider? timeProvider = null) + { + _objects = objects; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// The House tab's exact ListBox content — empty until the + /// first server notice arrives, matching retail's own PostInit (never + /// calls Update/DisplayHouseData). + public IReadOnlyList Lines + { + get { lock (_gate) return _lines; } + } + + /// Whether any of the four House notices (0x0225-0x0228) has + /// arrived this session. + public bool HasReceivedNotice + { + get { lock (_gate) return _hasReceivedNotice; } + } + + /// 0x0225 HouseData — RecvNotice_UpdateHouseData + /// (owned-house case). Only is consumed today; + /// the owned-house payload itself (buy/rent payments, times, location) + /// feeds ISSUES #413's remaining six builders, not yet ported. + public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid) + { + lock (_gate) + { + _hasReceivedNotice = true; + _ownsHouse = true; + Recompute(selfGuid); + } + } + + /// 0x0226 HouseStatus — RecvNotice_FailedHouseTransaction + /// (the "no house owned" reply to a HouseQuery, per ACE's + /// HandleActionQueryHouse). is + /// accepted for wire-shape completeness but intentionally UNUSED: + /// decomp-confirmed retail's own Update(uint32_t) overload never + /// reads its arg2 parameter — the wire WeenieError is discarded, + /// not surfaced as chat or panel text. + public void ApplyHouseStatus(uint weenieError, uint selfGuid) + { + _ = weenieError; + lock (_gate) + { + _hasReceivedNotice = true; + _ownsHouse = false; + Recompute(selfGuid); + } + } + + /// Generation reset — a fresh login must not show a previous + /// character's house-query result. Restores the exact pre-notice + /// "genuinely empty" state. + public void ResetSession() + { + lock (_gate) + { + _hasReceivedNotice = false; + _ownsHouse = false; + _lines = Array.Empty(); + } + } + + /// gmHouseUI::DisplayPurchaseTimeText @0x004a3110, + /// both branches now ported. Must hold . + 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.", + }; + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs b/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs index 45450a7f..9c42309b 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs @@ -50,18 +50,34 @@ public sealed class RuntimeSpellCastState public uint? LastRequestedTargetId { get; private set; } public event Action? StateChanged; - public bool IsTargetReady(uint spellId) + public bool IsTargetReady(uint spellId) => + EvaluateCastGate(spellId) + is SpellCastGate.NoTargetNeeded or SpellCastGate.TargetCompatible; + + /// + /// gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30's + /// plain-spell branch (m_endowmentItemID == 0) gate, split out + /// (night-round review F3) so the tooltip presenter can distinguish + /// retail's four states rather than just the enabled/disabled boolean + /// collapses them to. Byte-decoded call + /// sites: the untargeted/self-cast branch at 0x4c6f35, the + /// targeted-and-compatible branch at 0x4c6e57 + /// (ClientMagicSystem::ObjectCompatibleWithSpell @0x567c30), and + /// the two disabled tails at 0x4c6f04 (incompatible) and + /// 0x4c6e2b (nothing selected). + /// + public SpellCastGate EvaluateCastGate(uint spellId) { if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell)) - return false; + return SpellCastGate.Unknown; if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u) - return true; - return _selection.SelectedObjectId is uint target and not 0u - && _operations.IsTargetCompatible( - target, - spell, - showMessage: false); + return SpellCastGate.NoTargetNeeded; + if (_selection.SelectedObjectId is not (uint target and not 0u)) + return SpellCastGate.NoTargetSelected; + return _operations.IsTargetCompatible(target, spell, showMessage: false) + ? SpellCastGate.TargetCompatible + : SpellCastGate.TargetIncompatible; } public CastRequestResult Cast(uint spellId) @@ -159,3 +175,27 @@ public enum CastRequestResult MissingComponents, Unavailable, } + +/// +/// The four retail cast-button states gmSpellcastingUI:: +/// UpdateCastButtonTooltip @0x004c6a30's plain-spell branch +/// distinguishes — see . +/// +public enum SpellCastGate +{ + /// Spell metadata is missing / not known. + Unknown, + /// Untargeted, self-targeted, or no target mask — always + /// castable. Retail: "CAST %hs" @0x7b63a4. + NoTargetNeeded, + /// A target is selected and compatible. Retail: "CAST + /// %hs" @0x7b63a4, then " on %s" @0x7b6464 appended with + /// the target's name. + TargetCompatible, + /// A target is selected but incompatible. Retail: "You + /// must select an appropriate target for %hs" @0x7b6348. + TargetIncompatible, + /// No target is selected. Retail: "You must select a + /// target for %hs" @0x7b63b8. + NoTargetSelected, +} diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs index 786cc772..28441e7c 100644 --- a/src/AcDream.Runtime/RuntimeGenerationReset.cs +++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs @@ -60,15 +60,24 @@ public enum RuntimeGenerationResetStage /// beside its fellowship/allegiance precedents. /// Trade = 14, - BeginEntityRetirement = 15, - RetireEntities = 16, - DrainHostProjection = 17, - CompleteCanonicalEntities = 18, - CompleteHostProjection = 19, - ChatIdentity = 20, - PlayerSnapshots = 21, - PlayerIdentity = 22, - Complete = 23, + /// + /// Batch C (Map/House toolbar panel, 2026-08-17): the House tab's + /// query result is session-scoped like fellowship/allegiance/trade + /// above — a fresh login must not show a previous character's house + /// data. See 's class doc for why this + /// owner is lighter-weight than its three siblings (no disposal, no + /// construction-transaction Fault() point). + /// + 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( @@ -119,6 +128,7 @@ public sealed class RuntimeGenerationReset private readonly RuntimeFellowshipState _fellowship; private readonly RuntimeAllegianceState _allegiance; private readonly RuntimeTradeState _trade; + private readonly RuntimeHouseState _house; private ResetState? _state; private RuntimeGenerationToken _lastCompletedGeneration; private bool _hasCompletedGeneration; @@ -136,7 +146,8 @@ public sealed class RuntimeGenerationReset RuntimeLocalPlayerIdentityState identity, RuntimeFellowshipState fellowship, RuntimeAllegianceState allegiance, - RuntimeTradeState trade) + RuntimeTradeState trade, + RuntimeHouseState house) { _transit = transit ?? throw new ArgumentNullException(nameof(transit)); _communication = communication @@ -157,6 +168,7 @@ public sealed class RuntimeGenerationReset _allegiance = allegiance ?? throw new ArgumentNullException(nameof(allegiance)); _trade = trade ?? throw new ArgumentNullException(nameof(trade)); + _house = house ?? throw new ArgumentNullException(nameof(house)); } public RuntimeGenerationToken? ActiveRetiringGeneration => @@ -333,6 +345,9 @@ public sealed class RuntimeGenerationReset case RuntimeGenerationResetStage.Trade: Advance(state, _trade.Clear); break; + case RuntimeGenerationResetStage.House: + Advance(state, _house.ResetSession); + break; case RuntimeGenerationResetStage.BeginEntityRetirement: _ = _entityObjects.BeginSessionClear(); state.Retirements = _entityObjects diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 7cd25774..53179aae 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -88,7 +88,11 @@ public sealed record LiveSocialSessionBindings( RuntimeAllegianceState? Allegiance = null, // Secure trade (2026-08-14): the third sibling J-owner, same // 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); /// /// Owns every inbound subscription for one exact live session. Domain state @@ -322,6 +326,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting : null, onTradeClearAcceptance: social.Trade is { } tradeClear ? 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)); ConstructionCheckpoint(); diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index e8064b6a..c6bdfb7f 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -198,6 +198,14 @@ public sealed class RuntimeLiveEntitySessionController // truthful terminal admission edge. _initialLoginCompleteSent = true; _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(); } } diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index e8648e14..119b997a 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -257,7 +257,8 @@ public sealed class InteractionRetainedUiCompositionTests ClientTime: static () => 0d, Log: static _ => { }, GpuDevice: null!, - GpuFrameSource: null!); + GpuFrameSource: null!, + CurrentCalendar: static () => default); } public InteractionRetainedUiDependencies Dependencies { get; } diff --git a/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs index 8b4dc7ab..66b10dc1 100644 --- a/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs +++ b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs @@ -74,6 +74,46 @@ public sealed class RetailSkillFormulaTests Assert.Equal(uint.MaxValue, result); } + /// + /// F13 (night-round review): + /// previously read the raw signed int fields directly instead of + /// the SAME unsigned reinterpretation + /// uses. A multiplier whose stored bit pattern has the high bit set + /// would read as a small negative number here (failing the + /// >= 1 hasAttr gate, or printing a negative multiplier) instead + /// of the huge unsigned value TryCalculate actually computes + /// with for the SAME formula. + /// + [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); + } + + /// Same reinterpretation, but for the divisor and additive-bonus + /// suffixes rather than the multiplier — both must read unsigned too. + [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] public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs index c1cd9b66..d0374b5d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -1108,4 +1108,119 @@ public sealed class ConfigOptionsPageControllerTests + $"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( + 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); + } + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs index 29cd5e61..c6863bad 100644 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -272,6 +272,15 @@ public static class FixtureLoader public static ElementInfo LoadSocialPanelHostInfos() => LoadInfos("social_panel_2100006E_1000018F.json"); + /// The two-tab Map/House panel host slot 0x1000018C — + /// Batch C (overnight hover/UI round). This is what + /// actually mounts. + public static ImportedLayout LoadMapHouseHost() + => LayoutImporter.Build(LoadMapHouseHostInfos(), _ => (0u, 0, 0), null); + + public static ElementInfo LoadMapHouseHostInfos() + => LoadInfos("map_house_2100006E_1000018C.json"); + /// Configure Keyboard screen LayoutDesc 0x21000009 (standalone /// import — its own separate full-screen window, NOT nested under the Options /// panel's 0x2100006E host — Campaign OP slice OP8). diff --git a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs new file mode 100644 index 00000000..47ca4eef --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs @@ -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; + +/// +/// Controller-level tests for against +/// the committed map_house_2100006E_1000018C.json fixture (Batch C, +/// overnight hover/UI round) — the SAME LayoutImporter.ImportInfos(dats, +/// 0x2100006Eu, 0x1000018Cu) catalog import +/// performs against real +/// DATs. No DAT access, no live runtime — dat-free per the established +/// pattern. +/// +public sealed class MapHousePanelControllerTests +{ + /// Serves the town-hotspot template. Returns a + /// — matching the live template's own authored + /// Type 1 (MapHousePanelSlotProbeTests: "hotspot template + /// type=1") — so 's + /// marker is UiButton tooltip-text branch is actually exercised + /// by these tests. A real would set + /// DatElementId the same way + /// does, so tests that need to find these markers back by id after the + /// fact need it too. + private static UiElement? FakeHotspotTemplate(uint layoutId, uint elementId) + => new UiButton(new ElementInfo(), static _ => (0u, 0, 0)) + { + Width = 10f, + Height = 10f, + DatElementId = elementId, + }; + + /// The + /// seam: builds m_pMap's two button-swallowed icon children from their + /// OWN 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 + /// pageInfo and built through this seam instead). Mirrors + /// production's LayoutImporter.Build(info, ...).Root, which sets + /// DatElementId from the info's own id. + private static UiElement? FakeIconBuilder(ElementInfo info) + => new UiButton(new ElementInfo(), static _ => (0u, 0, 0)) + { + Width = 10f, + Height = 10f, + DatElementId = info.Id, + }; + + /// The House ListBox's own row template resolves to a + /// in the live DAT (MapHousePanelSlotProbeTests: + /// "row template type=12" — UIElement_Text), unlike the Map tab's + /// UiButton hotspots above — a fresh instance per call, matching + /// production's real resolver. + private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId) + => new UiText { Width = 280f, Height = 28f }; + + private static MapHousePanelController.Callbacks MakeCallbacks( + List? calls = null, + Func? currentCalendar = null, + Func? playerCellId = null, + Func? housePosition = null, + Func>? houseLines = null) + { + calls ??= new List(); + 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()), + 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(controller!.Root); + } + + /// Pins the authored tab table exactly as read from the live + /// DATs (MapHousePanelSlotProbeTests): two entries, Map is the + /// sole default. + [Fact] + public void TabTable_MatchesLiveDatPairing_MapIsDefault() + { + ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); + var tabs = Assert.IsType(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(); + 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(); + MapHousePanelController? controller = + MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls)); + Assert.NotNull(controller); + + UiElement? close = layout.FindElement(0x100001F5u); + Assert.IsType(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(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(box); + Assert.Equal(0, listBox.ContentHeight); + } + + /// + /// Batch C House-ownership-text closer (2026-08-17): the ONE + /// decomp-verified gmHouseUI::DisplayPurchaseTimeText @0x004a3110 + /// line a houseless character's HouseQuery response renders — proves + /// 's revision-gated + /// poll actually rebuilds the + /// authored row template with real text end-to-end, the same way + /// proves the text composition in + /// isolation. + /// + [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(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( + listBox.ViewportForTest); + Assert.Single(viewport.Children); + var row = Assert.IsType(viewport.Children[0]); + Assert.Equal( + "You may buy another house immediately.", + Assert.Single(row.LinesProvider()).Text); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelLiveDatMountTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelLiveDatMountTests.cs new file mode 100644 index 00000000..a784036b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelLiveDatMountTests.cs @@ -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; + +/// +/// 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. +/// +/// +/// The fixture-based 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 's exact +/// recipe against the INSTALLED DATs (sprite/font resolution stubbed — +/// structure only, same as every committed-fixture build): the panel-slot +/// LayoutImporter.ImportInfos(dats, hostLayoutId, slotElementId) +/// import, , real +/// import-then-build hotspot/template resolution, and the +/// build seam — +/// then asserts the two icons actually materialize as built elements. +/// +/// +/// +/// Gated like every other installed-DAT family here: +/// [InstalledDatFact], opt in with +/// ACDREAM_PROBE_LIVE_MOUNT=1 (the App suite's live-DAT baseline +/// mode); ACDREAM_DAT_DIR overrides the ordinary +/// Documents/Asheron's Call location. +/// +/// +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(), + 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); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs new file mode 100644 index 00000000..9f2850b2 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelSlotProbeTests.cs @@ -0,0 +1,215 @@ +using System.IO; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 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 +/// gmPanelUI::SetupChildren dump) already resolved host +/// 0x2100006E slot 0x1000018C / RetailPanelCatalog id +/// 16 as the Map/House tab host, and toolbar button +/// 0x1000019A 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 +/// m_pMap marker-area rect + hotspot template attrs (0x47/ +/// 0x48), and the House ListBox's authored default content. +/// +/// +/// Kept as a permanent env-gated pin (like FaPanelSlotProbeTests) — +/// it documents authored truth for future sessions, not a one-shot +/// throwaway. +/// +/// +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; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs new file mode 100644 index 00000000..006079bf --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs @@ -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; + +/// +/// Unit coverage for 's pure math: the +/// calendar formatter ("Date: %s\nTime: %s", +/// gmMapUI::Update @0x004a1eb0) and the 53-town static table +/// (verbatim port of s_rgLocations). Marker-placement math itself is +/// / — both +/// already unit-tested elsewhere; this file only proves the wiring +/// reproduces their output through the real fixture (no re-derivation). +/// +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(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())); + + 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())); + + 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())); + + MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks); + Assert.NotNull(controller); + + Assert.Null( + UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId)); + + var coordinateText = Assert.IsType( + 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())); + + 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); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs index e540eafe..a5b810a8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs @@ -153,6 +153,11 @@ public sealed class RetailLayoutFixtureGenerator // lane-A unknowns U3/U4/U6/U7/U10. (SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId, "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); diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs index 4095519f..4cdc83a0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -689,6 +689,128 @@ public sealed class RetailTooltipPresenterTests 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] public void WorldHover_ReEvaluatesGateAndTextOnlyOnTheFoundGuidEdge() { diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs index e0acedc4..8903bee2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs @@ -152,6 +152,13 @@ public sealed class SpellcastingUiControllerTests CurrentlyEquippedLocation = EquipMask.Held, SpellId = 2670u, 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(); @@ -711,6 +718,170 @@ public sealed class SpellcastingUiControllerTests 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(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(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(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(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(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(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( ImportedLayout layout, Spellbook spellbook, @@ -720,13 +891,14 @@ public sealed class SpellcastingUiControllerTests UiShortcutDigitGraphics? shortcutDigits = null, uint emptySlotSprite = 0u, SelectionState? selection = null, - Action? examineSpell = null) + Action? examineSpell = null, + IRuntimeSpellCastOperations? operations = null) { SelectionState selectionState = selection ?? new SelectionState(); var casting = new RuntimeSpellCastState( spellbook, selectionState, - new NoopSpellCastOperations()); + operations ?? new NoopSpellCastOperations()); return SpellcastingUiController.Bind( layout, spellbook, casting, objects, () => 1u, spellId => spellId, @@ -760,6 +932,57 @@ public sealed class SpellcastingUiControllerTests public void IncrementBusy() { } } + /// Same as 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. + 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() { } + } + + /// Minimal builder for cast-gate + /// tests — most of the record's fields are irrelevant to + /// . + 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) { foreach (UiElement child in parent.Children) diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/map_house_2100006E_1000018C.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/map_house_2100006E_1000018C.json new file mode 100644 index 00000000..a7f6053a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/map_house_2100006E_1000018C.json @@ -0,0 +1,4602 @@ +{ + "Id": 268435852, + "Type": 8, + "X": 0, + "Y": 0, + "Width": 300, + "Height": 362, + "OriginalParentWidth": 0, + "OriginalParentHeight": 0, + "HasOriginalParentSize": false, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 11, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "88": { + "Kind": 0, + "MasterPropertyId": 88, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "46": { + "Kind": 7, + "MasterPropertyId": 46, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 8, + "MasterPropertyId": 47, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": { + "48": { + "Kind": 0, + "MasterPropertyId": 48, + "UnsignedValue": 268435955, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "49": { + "Kind": 0, + "MasterPropertyId": 49, + "UnsignedValue": 268435958, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "50": { + "Kind": 1, + "MasterPropertyId": 50, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + }, + { + "Kind": 8, + "MasterPropertyId": 47, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": { + "48": { + "Kind": 0, + "MasterPropertyId": 48, + "UnsignedValue": 268435956, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "49": { + "Kind": 0, + "MasterPropertyId": 49, + "UnsignedValue": 268435959, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "50": { + "Kind": 1, + "MasterPropertyId": 50, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + ], + "StructValue": {} + }, + "87": { + "Kind": 0, + "MasterPropertyId": 87, + "UnsignedValue": 268435478, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "268435497": { + "Kind": 0, + "MasterPropertyId": 268435497, + "UnsignedValue": 16, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435955, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 138, + "Height": 25, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "35": { + "Kind": 4, + "MasterPropertyId": 35, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741824, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "37": { + "Kind": 4, + "MasterPropertyId": 37, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "21": { + "Kind": 0, + "MasterPropertyId": 21, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "83": { + "Kind": 1, + "MasterPropertyId": 83, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "23": { + "Kind": 5, + "MasterPropertyId": 23, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 148800818, + "TableId": 587202561, + "Override": 0, + "English": 1, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "11": { + "Id": 11, + "Name": "Closed", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 127, + "Green": 127, + "Red": 127, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + }, + "12": { + "Id": 12, + "Name": "Open", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 204, + "Green": 204, + "Red": 204, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 11, + "FontDid": 1073741824, + "HJustify": 1, + "VJustify": 1, + "FontColor": { + "X": 0.49803922, + "Y": 0.49803922, + "Z": 0.49803922, + "W": 1 + }, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "Closed", + "Children": [ + { + "Id": 268436537, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 17, + "Height": 25, + "OriginalParentWidth": 100, + "OriginalParentHeight": 25, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "11": { + "Id": 11, + "Name": "Closed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687251, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "12": { + "Id": 12, + "Name": "Open", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687250, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "Closed": { + "Item1": 100687251, + "Item2": 3 + }, + "Open": { + "Item1": 100687250, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435689, + "Type": 3, + "X": 17, + "Y": 0, + "Width": 66, + "Height": 25, + "OriginalParentWidth": 100, + "OriginalParentHeight": 25, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "11": { + "Id": 11, + "Name": "Closed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687253, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "12": { + "Id": 12, + "Name": "Open", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687252, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "Closed": { + "Item1": 100687253, + "Item2": 3 + }, + "Open": { + "Item1": 100687252, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435989, + "Type": 3, + "X": 83, + "Y": 0, + "Width": 17, + "Height": 25, + "OriginalParentWidth": 100, + "OriginalParentHeight": 25, + "HasOriginalParentSize": true, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "11": { + "Id": 11, + "Name": "Closed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687255, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "12": { + "Id": 12, + "Name": "Open", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687254, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "Closed": { + "Item1": 100687255, + "Item2": 3 + }, + "Open": { + "Item1": 100687254, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435956, + "Type": 12, + "X": 138, + "Y": 0, + "Width": 138, + "Height": 25, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "35": { + "Kind": 4, + "MasterPropertyId": 35, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741824, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "37": { + "Kind": 4, + "MasterPropertyId": 37, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "21": { + "Kind": 0, + "MasterPropertyId": 21, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "83": { + "Kind": 1, + "MasterPropertyId": 83, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "23": { + "Kind": 5, + "MasterPropertyId": 23, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 1502562, + "TableId": 587202561, + "Override": 0, + "English": 1, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "11": { + "Id": 11, + "Name": "Closed", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 127, + "Green": 127, + "Red": 127, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + }, + "12": { + "Id": 12, + "Name": "Open", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 204, + "Green": 204, + "Red": 204, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 11, + "FontDid": 1073741824, + "HJustify": 1, + "VJustify": 1, + "FontColor": { + "X": 0.49803922, + "Y": 0.49803922, + "Z": 0.49803922, + "W": 1 + }, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "Closed", + "Children": [ + { + "Id": 268436537, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 17, + "Height": 25, + "OriginalParentWidth": 100, + "OriginalParentHeight": 25, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "11": { + "Id": 11, + "Name": "Closed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687251, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "12": { + "Id": 12, + "Name": "Open", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687250, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "Closed": { + "Item1": 100687251, + "Item2": 3 + }, + "Open": { + "Item1": 100687250, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435689, + "Type": 3, + "X": 17, + "Y": 0, + "Width": 66, + "Height": 25, + "OriginalParentWidth": 100, + "OriginalParentHeight": 25, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "11": { + "Id": 11, + "Name": "Closed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687253, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "12": { + "Id": 12, + "Name": "Open", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687252, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "Closed": { + "Item1": 100687253, + "Item2": 3 + }, + "Open": { + "Item1": 100687252, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435989, + "Type": 3, + "X": 83, + "Y": 0, + "Width": 17, + "Height": 25, + "OriginalParentWidth": 100, + "OriginalParentHeight": 25, + "HasOriginalParentSize": true, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "11": { + "Id": 11, + "Name": "Closed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687255, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "12": { + "Id": 12, + "Name": "Open", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100687254, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "Closed": { + "Item1": 100687255, + "Item2": 3 + }, + "Open": { + "Item1": 100687254, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435957, + "Type": 1, + "X": 276, + "Y": 0, + "Width": 24, + "Height": 25, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "18": { + "Kind": 0, + "MasterPropertyId": 18, + "UnsignedValue": 268435478, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "3": { + "Id": 3, + "Name": "Normal_pressed", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100668308, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": { + "File": 100668307, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 1, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "Normal": { + "Item1": 100668307, + "Item2": 1 + }, + "Normal_pressed": { + "Item1": 100668308, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "Normal", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435958, + "Type": 268435494, + "X": 0, + "Y": 25, + "Width": 300, + "Height": 575, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 4, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "88": { + "Kind": 0, + "MasterPropertyId": 88, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "87": { + "Kind": 0, + "MasterPropertyId": 87, + "UnsignedValue": 268435479, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435945, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 300, + "Height": 600, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435737, + "Type": 3, + "X": 279, + "Y": 33, + "Width": 21, + "Height": 535, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 4, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100668018, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100668018, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435738, + "Type": 3, + "X": 0, + "Y": 568, + "Width": 300, + "Height": 32, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 5, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100668016, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100668016, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435728, + "Type": 3, + "X": 22, + "Y": 33, + "Width": 257, + "Height": 535, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100668015, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100668015, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435735, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 300, + "Height": 33, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100668019, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100668019, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435736, + "Type": 3, + "X": 0, + "Y": 33, + "Width": 22, + "Height": 535, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100668017, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100668017, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435946, + "Type": 3, + "X": 0, + "Y": 135, + "Width": 300, + "Height": 330, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 3, + "Right": 1, + "Bottom": 3, + "ReadOrder": 6, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435947, + "Type": 12, + "X": 21, + "Y": 4, + "Width": 227, + "Height": 30, + "OriginalParentWidth": 300, + "OriginalParentHeight": 330, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741826, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "39": { + "Kind": 1, + "MasterPropertyId": 39, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "21": { + "Kind": 0, + "MasterPropertyId": 21, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 1073741826, + "HJustify": 1, + "VJustify": 1, + "FontColor": { + "X": 0, + "Y": 0, + "Z": 0, + "W": 1 + }, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435948, + "Type": 1, + "X": 21, + "Y": 36, + "Width": 257, + "Height": 267, + "OriginalParentWidth": 300, + "OriginalParentHeight": 330, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100668029, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": { + "268435534": { + "Kind": 4, + "MasterPropertyId": 268435534, + "UnsignedValue": 0, + "IntegerValue": 6, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "268435535": { + "Kind": 4, + "MasterPropertyId": 268435535, + "UnsignedValue": 0, + "IntegerValue": 247, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "71": { + "Kind": 0, + "MasterPropertyId": 71, + "UnsignedValue": 268435952, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "268435536": { + "Kind": 4, + "MasterPropertyId": 268435536, + "UnsignedValue": 0, + "IntegerValue": 8, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "72": { + "Kind": 2, + "MasterPropertyId": 72, + "UnsignedValue": 553648166, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "268435537": { + "Kind": 4, + "MasterPropertyId": 268435537, + "UnsignedValue": 0, + "IntegerValue": 258, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100668029, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435949, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 17, + "Height": 16, + "OriginalParentWidth": 257, + "OriginalParentHeight": 267, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100683024, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": { + "59": { + "Kind": 1, + "MasterPropertyId": 59, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100683024, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": true, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435950, + "Type": 1, + "X": 0, + "Y": 0, + "Width": 8, + "Height": 8, + "OriginalParentWidth": 257, + "OriginalParentHeight": 267, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100683025, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": { + "80": { + "Kind": 3, + "MasterPropertyId": 80, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "59": { + "Kind": 1, + "MasterPropertyId": 59, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "71": { + "Kind": 0, + "MasterPropertyId": 71, + "UnsignedValue": 268436376, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "72": { + "Kind": 2, + "MasterPropertyId": 72, + "UnsignedValue": 553648193, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "73": { + "Kind": 5, + "MasterPropertyId": 73, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 258601413, + "TableId": 587202561, + "Override": 0, + "English": 1, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "19": { + "Kind": 1, + "MasterPropertyId": 19, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "75": { + "Kind": 1, + "MasterPropertyId": 75, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": true, + "IncorporationFlags": 1, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100683025, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435953, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 8, + "Height": 8, + "OriginalParentWidth": 8, + "OriginalParentHeight": 8, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": {} + } + }, + "1": { + "Id": 1, + "Name": "Normal", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "59": { + "Kind": 1, + "MasterPropertyId": 59, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "2": { + "Id": 2, + "Name": "Normal_rollover", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "59": { + "Kind": 1, + "MasterPropertyId": 59, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435977, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 1, + "Height": 100, + "OriginalParentWidth": 100, + "OriginalParentHeight": 100, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100682953, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100682953, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435978, + "Type": 3, + "X": 99, + "Y": 0, + "Width": 1, + "Height": 100, + "OriginalParentWidth": 100, + "OriginalParentHeight": 100, + "HasOriginalParentSize": true, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100682953, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100682953, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435979, + "Type": 3, + "X": 0, + "Y": 99, + "Width": 100, + "Height": 1, + "OriginalParentWidth": 100, + "OriginalParentHeight": 100, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 4, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100682953, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100682953, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268436149, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 100, + "Height": 1, + "OriginalParentWidth": 100, + "OriginalParentHeight": 100, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100682953, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100682953, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": true, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": true, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": true, + "TooltipText": { + "Token": 0, + "StringId": 258601413, + "TableId": 587202561, + "Override": 0, + "English": 1, + "Comment": 0 + }, + "TooltipRootElementId": 268436376, + "TooltipLayoutDid": 553648193, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": 0, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 268435952, + "TooltipLayoutDid": 553648166, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435951, + "Type": 12, + "X": 21, + "Y": 303, + "Width": 257, + "Height": 20, + "OriginalParentWidth": 300, + "OriginalParentHeight": 330, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 3, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741828, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "39": { + "Kind": 1, + "MasterPropertyId": 39, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 1073741828, + "HJustify": 1, + "VJustify": 1, + "FontColor": { + "X": 0, + "Y": 0, + "Z": 0, + "W": 1 + }, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + }, + { + "Id": 268435959, + "Type": 268435493, + "X": 0, + "Y": 25, + "Width": 300, + "Height": 575, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 5, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100682946, + "DrawMode": 1 + }, + "Cursor": null, + "Properties": { + "Values": { + "88": { + "Kind": 0, + "MasterPropertyId": 88, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "87": { + "Kind": 0, + "MasterPropertyId": 87, + "UnsignedValue": 268435480, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": { + "": { + "Item1": 100682946, + "Item2": 1 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435942, + "Type": 5, + "X": 5, + "Y": 5, + "Width": 290, + "Height": 590, + "OriginalParentWidth": 300, + "OriginalParentHeight": 600, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "100": { + "Kind": 7, + "MasterPropertyId": 100, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 8, + "MasterPropertyId": 101, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": { + "99": { + "Kind": 2, + "MasterPropertyId": 99, + "UnsignedValue": 553648165, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "98": { + "Kind": 0, + "MasterPropertyId": 98, + "UnsignedValue": 268435943, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + ], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "Outline": false, + "OutlineColor": null, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [], + "TabTable": [], + "TemplateList": [ + { + "TemplateLayoutId": 553648165, + "TemplateElementId": 268435943 + } + ], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null + } + ], + "TabTable": [ + { + "ButtonElementId": 268435955, + "PageElementId": 268435958, + "IsDefault": true + }, + { + "ButtonElementId": 268435956, + "PageElementId": 268435959, + "IsDefault": false + } + ], + "TemplateList": [], + "LedCheckedSprite": 0, + "LedUncheckedSprite": 0, + "ScrollbarElementId": 0, + "Invisible": false, + "MarginLeft": 0, + "MarginRight": 0, + "MarginTop": 0, + "MarginBottom": 0, + "TooltipEnabled": false, + "TooltipText": null, + "TooltipRootElementId": 0, + "TooltipLayoutDid": 0, + "TooltipTextChildElementId": 0, + "TooltipDelaySeconds": null, + "MaxWidth": null, + "MinWidth": null, + "MaxHeight": null, + "MinHeight": null +} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs index 98ceeba6..02c6a176 100644 --- a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs @@ -76,4 +76,75 @@ public sealed class UiTemplateListBoxViewportTests Assert.True(row1.Visible, "row 1 culled — the #372 blank-tab bug"); Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug"); } + + /// + /// #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 + /// 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 + /// carrying a real 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. + /// + [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)"); + } } diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 5f7fd456..f093d5d2 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -1771,6 +1771,48 @@ public sealed class GameEventWiringTests Assert.True(observed.Value.IsLoggedIn); } + /// Batch C (Map/House toolbar panel, 2026-08-17): all four + /// house wire opcodes (0x0225-0x0228) reach their registered + /// callbacks. + [Fact] + public void WireAll_HouseFamily_ReachesTheirCallbacks() + { + var dispatcher = new GameEventDispatcher(); + GameEvents.HouseData? data = null; + uint? status = null; + uint? rentTime = null; + IReadOnlyList? 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( ushort spellId, ushort layer, diff --git a/tests/AcDream.Core.Net.Tests/Messages/HouseEventsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/HouseEventsTests.cs new file mode 100644 index 00000000..bf15f67e --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/HouseEventsTests.cs @@ -0,0 +1,156 @@ +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// 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. +/// +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())); + } + + [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)); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs new file mode 100644 index 00000000..71133b14 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHouseStateTests.cs @@ -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; + +/// +/// House-tab conformance (Batch C, 2026-08-17): the ONE decomp-verified +/// line gmHouseUI::DisplayPurchaseTimeText @0x004a3110 emits for a +/// houseless/fresh character, and the wait-period-not-expired case that +/// stays empty (unrecoverable strftime format, ISSUES #413 item 2). +/// +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(), + Rent: Array.Empty(), + 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; + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 925af86f..02b9342d 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -165,10 +165,18 @@ public sealed class RuntimeLiveEntitySessionControllerTests Spawn(playerGuid, incarnation: 1); 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(ClientCommandRequests.BuildHouseQuery(1u), gameActions[1]); sink.Spawned(spawn); - Assert.Single(gameActions); + Assert.Equal(2, gameActions.Count); gameActions.Clear(); sink.TeleportStarted(1u);