From 5242de9f15a24fd41fb18c055951a4515cb725fe Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 11 Aug 2026 01:26:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20OP2=20re-review=20closure=20(coordin?= =?UTF-8?q?ator=20pass)=20=E2=80=94=20AP-195,=20tooltip=20port,=20zero-chi?= =?UTF-8?q?ldren=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the mechanism lens's REOPEN (one MUST-FIX) and both lenses' small residuals on the OP2 rework (b236a442); the blast lens re-review was CLOSED outright. Fable-direct per the two-failure escalation rule. - AP-195 filed: UIOption_CheckboxBitfield64 ports HALF of Refresh @0x004859C0 — the ANY-set checkbox predicate is exact, but the ALL-set LED media swap (P0x10000082=0x06004D17 / P0x10000083=0x06004D19) and the ListBox self-sizing tail (ResizeTo/CalculatePaperSize — the block IS a UIElement_ListBox in retail) are unported, and the block's row stacking is a second divergent implementation beside UiTemplateListBox. All due at OP5 before the Chat tab's connected gate; the IsSet doc comment now names both halves instead of quoting only the ported one. - Row tooltips: UiButton gains settable TooltipText surfaced through the shared GetTooltipText hover pipeline (UiCatalogSlot's pattern); UiCheckboxBitfield64.AddChild applies the row tooltip retail stamps in CreateChildren @0x00485DF0, and documents that the 0x10000084 row-index attribute stamp is deliberately replaced by the typed mask closure. - AD-73 addendum: UiTemplateListBox.ConsumesDatChildren=true is inert only while no authored Type-5 element carries children — that premise is now conformance-PINNED across all 32 fixtures (a future DAT regeneration surfacing an authored child fails the build instead of silently dropping it). - Plan doc: OP2's contract names UiTabPanel.cs (retail UIElement_Panel), not the fictional-class-named UiTabControl.cs; ledger records OP1 and OP2 both CLOSED. Full Release suite: 12,871 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 5 +- .../2026-08-10-options-panel-campaign.md | 8 +- .../research/2026-08-11-op2-rereview-blast.md | 487 ++++++++++++++++++ .../2026-08-11-op2-rereview-mechanism.md | 376 ++++++++++++++ src/AcDream.App/UI/UiButton.cs | 11 + src/AcDream.App/UI/UiCheckboxBitfield64.cs | 13 +- .../OP2ReworkBlastRadiusConformanceTests.cs | 49 ++ 7 files changed, 943 insertions(+), 6 deletions(-) create mode 100644 docs/research/2026-08-11-op2-rereview-blast.md create mode 100644 docs/research/2026-08-11-op2-rereview-mechanism.md diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 7c774f64..53c72bea 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -105,7 +105,7 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| 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` | +| 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) | | ~~AD-54~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** Every stored walkable polygon now routes unconditionally to `PrecipiceSlide`, including a plane steeper than `FloorZ`; the invented steep-walkable reroute to `CliffSlide` is gone. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` pc:273001-273090 (0050b3d0) | | ~~AD-55~~ | **RETIRED 2026-07-30 at `252e8068` — and RE-RETIRED 2026-08-07 after a revert resurrected the row text.** The production constant has been the byte-confirmed `0.98480775f` (cos 10°) since `252e8068`, which also struck this row. Five hours later `a8a7d64b` — reverting the UNRELATED TS-4 commit `5e2be19b` — restored this file's older hunk and resurrected the un-struck row while leaving the code fixed. The zombie row then cost a full duplicate byte-derivation on 2026-08-07 (independent confirmation, identical result: qword [0x007c6b28] = π/18 exactly, live FCOS, threshold cos(10°) = 0.984807753; see `docs/research/2026-08-07-ad55-sledding-constant-byte-decode.md`). The old `0.99999536f` was ACE's error — the radian literal evaluated in degree mode — and acdream inherited then corrected it. **Process rule filed to memory: reverting any commit that touched this register must re-verify EVERY row the revert's register hunk touches, because whole-hunk reverts of single-line rows silently undo unrelated row edits.** Original text, retained: `calc_friction`'s Sledding slope-flatness test compares `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw retail decomp literally computes `__fcos(0.17453292519943295)` (= cos(10°) ≈ 0.984808) and compares that against `contact_plane.N.z` — physically very different tests (0.175° accepts only essentially-perfectly-flat ground; 10° accepts any modest slope) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, the Sledding near-flat branch) | Filed 2026-07-30 splitting AP-7's retirement (Campaign P Slice P2). Two hypotheses, neither confirmed this pass: (a) BN misdecompiled a raw float-constant load as an `__fcos()` call (a known BN artifact class), or (b) ACE's own port made an independent error and cos(10°) is correct. `0.99999536f` is kept provisionally — least churn, since it is what acdream's own prior (structurally unreachable) dead code already had — pending a live Ghidra decompile of `0050ee70` checking whether the FCOS opcode is real or a raw `FLD` of one of these two constants | Currently harmless in production: nothing sets `PhysicsState.Sledding` client-side (see #166 research), so this branch is unreachable either way. The moment a data-authored Sledding toggle exists, the wrong constant changes which slopes get the light 0.2f sled-friction override vs. the heavier default | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70), the `__fcos(0.17453292519943295)` slope-flatness comparison; ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141 (`0.99999536f`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1, §7 item 3 | @@ -171,7 +171,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 135 active rows (AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 136 active rows (AP-195 filed 2026-08-11 at the Campaign OP OP2 re-review closure — `UIOption_CheckboxBitfield64::Refresh`'s ALL-set LED media swap and the `ResizeTo`/`CalculatePaperSize` self-sizing tail are not ported, and the block's row stacking is a second, divergent implementation of the ListBox mechanism, all due at OP5; AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -180,6 +180,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 | |---|---|---|---|---|---| | AP-194 | `CharacterOptionTable`'s `ClientDefault` column (what the Character tab's Defaults button restores) disagrees with the raw constructor default word for three ids: `ConfirmVolatileRareUse` (`0x2D`), `ShowHelm` (`0x2F`), and `ShowCloak` (`0x32`) are all ON in retail's constructor default `CharacterOptions2 = 0x00948700` (`PlayerModule::PlayerModule @0x005D51F0`, byte-verified literal write) but report default-OFF via `PlayerModule::GetDefaultOptionValue @0x005D2A30`, whose own per-option table stops at id `0x2A` and returns `false` for everything past it. This is retail's OWN behavior, reproduced deliberately — the Defaults button does not reproduce a fresh `PlayerModule`. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`ClientDefault` column; see the type's XML doc) | Byte-verified at both addresses (wire research §2.5 for the constructor literals, §8.2 for `GetDefaultOptionValue`'s own table and bounds check) — this is not a guess, it is retail's documented quirk. "Fixing" it to match the constructor default would make acdream's Defaults button MORE correct than retail's own, which is the opposite of this project's goal. | A future OP-campaign slice (OP4, the Character tab's Defaults button) must consult THIS column, not the constructor default word, or a future reader may "fix" this back and silently diverge from retail. | `PlayerModule::GetDefaultOptionValue @0x005D2A30`; `PlayerModule::PlayerModule @0x005D51F0`; `docs/research/2026-08-10-set-character-options-wire.md` §8.2 | +| AP-195 | **Filed 2026-08-11 (Campaign OP OP2 re-review closure; supersedes the ported half of retired TS-72).** `UIOption_CheckboxBitfield64` ports HALF of retail's `Refresh @0x004859C0`: the ANY-set predicate driving each row's checkbox bool is exact, but the same retail pass also computes the ALL-set predicate to swap each row's LED media between the two surfaces authored on template consumer `0x10000520` (`P0x10000082 = 0x06004D17` checked-art / `P0x10000083 = 0x06004D19` unchecked-art) — acdream reads neither property anywhere. Retail's `CreateChildren` tail also self-sizes the block (`ResizeTo(GetWidth(), CalculatePaperSize(0, -1))`) because `UIOption_CheckboxBitfield64` IS a `UIElement_ListBox` (its `PostInit` tail-calls `UIElement_Scrollable::PostInit`); acdream's block instead keeps its authored 272×100 extent while stacking rows itself — a second, divergent implementation of the row-stacking mechanism beside `UiTemplateListBox` (which stacks into a lazy `UiScrollablePanel`). The row-index attribute stamp (`SetAttribute_Int(cb, 0x10000084, i)` @`0x00485E3E`) is deliberately replaced by a typed mask closure — equivalent click routing without the attribute round-trip; NOT part of this row's gap. `src/AcDream.App/UI/UiCheckboxBitfield64.cs` (`IsSet`/`AddChild`) | The checkbox bool — the half every wire-visible behavior flows through — is exact and conformance-tested (multi-bit discriminating test); the LED media swap and self-sizing are presentation-only, invisible until the Chat tab actually mounts a bitfield block, and porting them properly wants the ListBox unification (reuse `UiTemplateListBox`'s stacking or record why not) rather than a third stacking path bolted on in a closure commit | The Chat tab's five filter blocks (the only authored bitfield consumers, 12/13 rows each) would render rows whose LED art never switches between checked/unchecked surfaces and whose block height stays the authored 272×100 instead of growing to fit 13 rows (~260 px + chrome) — visibly wrong the moment OP5 mounts them; both gaps MUST close (or this row be consciously re-scoped) in OP5 before its connected gate | `UIOption_CheckboxBitfield64::Refresh @0x004859C0`; `CreateChildren @0x00485DF0`; `ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`); `docs/research/2026-08-11-op2-rereview-mechanism.md` §2.1–§2.3 | | AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 | | AP-172 | **Filed 2026-08-08 (#354 fix — spell-bar drag reorder).** Retail removes a lifted favorite from `PlayerModule` (+ UI list + wire) the instant a drag starts and the remaining shortcuts visibly slide left to close the gap for the rest of the gesture (`RecvNotice_ItemListBeginDrag` → `RemoveSpellFromMenu`, live). acdream's controller performs the same PlayerModule/wire removal at drag-begin but DEFERS the whole favorite-list's visual rebuild until the drag concludes (drop or off-bar release) — the lifted cell's icon stays visible in its old slot and siblings do not slide until release, instead of reflowing continuously through the gesture. `DropFavorite` compensates by porting retail's own `AddFavorite`-side index adjustment (decrement the target index by one when the lifted item's original index was before it) against the now-intentionally-stale sibling numbering, so the FINAL landed position is byte-identical to retail's in every case exercised (`DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire`). **NARROWED + CORRECTED 2026-08-08 (drop-ring change).** Correction: this row originally claimed empty-tail-slot drops were "already-live-count-relative and are untouched" — false. The #354 `-1` adjustment sat inside `DropFavorite`, which the empty-cell path also calls, so its live-count-clamped (post-lift-numbered) index was double-corrected: lifting a non-last favorite onto the empty tail landed it second-to-last instead of last. `FavoriteDropIndex` is now THE one landing computation and applies retail's rule exactly — the `-1` is gated on the lifted spell's pre-lift-numbered removal site (retail's `RemoveSpellFromMenu`-return-gated decrement @0x004C7157), which for a live-numbered empty-tail target is retail's `RemoveSpellFromMenu == -1` no-adjustment case (test `SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd` fails against the double-correcting code). Narrowing: the mid-drag presentation now includes retail's authored drag-over Accept ring — `SpellCastSubMenu::OnItemListDragOver` @0x004C5990 setting the per-cell authored DragAccept child (element 0x1000045A, `UIElement_UIItem::PostInit` @0x004E1870) to `ItemSlot_DragOver_Accept` (UIStateId 0x10000040 → authored art 0x060011F9) on the hovered cell while a spell drag is live, cleared on leave/drop (`UiCatalogSlot.DragOverAcceptance` → `UiItemSlot.DrawDragAcceptOverlay`), with the ring and the drop sharing `FavoriteDropIndex` so the ring cannot promise a different landing. | `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`BeginFavoriteDrag`, `EndFavoriteDrag`, `DropFavorite`, `Tick` — the `_favoriteDragActive` gate) | `UiRoot`'s subtree-removal safety net (`ClearSubtreeOwnership`, `UiRoot.cs:240-247`) cancels any in-flight drag whose source widget is destroyed, and `Rebuild()` tears down and recreates every cell in the list (`UiItemList.Flush` → `RemoveChild` per cell) rather than incrementally diffing. Left unguarded, the press-time removal's `SpellbookChanged` event would let the very next per-frame `Tick()` (production drives this unconditionally via `RetailUiRuntime.Tick`) destroy the cell driving the gesture and silently cancel the reorder before the user could complete the drop — this was the reported bug. Deferring the rebuild for the gesture's duration is the minimal fix that does not touch the shared `UiRoot` drag machinery every other panel (toolbar/inventory/vendor/paperdoll) also depends on. | A future rewrite that makes `Rebuild()` an incremental per-cell diff (add/remove/reflow one cell) instead of flush-and-recreate-all would make this deferral unnecessary and should retire this row along with it — until then, a player watching their OWN spell bar mid-drag sees the vacated slot's icon linger and siblings snap into place only on release, rather than reflowing live as retail does — and one ring consequence of that frozen bar: when dragging rightward past the source, the Accept ring's SCREEN slot sits one cell right of where the icon finally lands (retail's live-reflowed bar makes them coincide); the ring is on the correct CELL in both — the spell lands immediately before that cell's spell, retail's exact insert-before semantic. No effect on final position, the wire pair sent, or any other panel; cross-window spellbook→favorite drops are live-count-relative and untouched (the empty-tail claim this sentence used to carry was corrected 2026-08-08 — see the Divergence column). | `gmSpellcastingUI::RecvNotice_ItemListBeginDrag` @0x004C7360 (`SpellCastSubMenu::RemoveSpellFromMenu`, immediate live-list removal at lift); `SpellCastSubMenu::AddFavorite` @0x004C7060 (`RemoveSpellFromMenu`'s return value gating the `-1`-if-lifted-before-target `m_numSpells` adjustment before `ItemList_InsertSpellShortcut`); `PlayerModule::AddSpellFavorite` @0x005D43E0 (`InsertPos`); `PlayerModule::RemoveSpellFavorite` @0x005D4910 | | AP-160 | **Filed 2026-08-07, Slice 5.3 (vendor browse lifecycle). CORRECTED AND EXTENDED 2026-08-07 at the Slice 5.3 review corrections (fixes 4/5).** **Correction (fix 4):** this row's own Retail-oracle citation originally grouped `WorldObject_Use.cs:50,57` under the SAME citation as `Vendor.CheckClose`/`GetCylinderDistance`, which read as if the `wo.UseRadius ?? 0.6f` fallback lived inside the close watcher. It does not: `WorldObject_Use.cs:50,57` is `WorldObject.IsWithinUseRadiusOf`, the APPROACH check ("how close you need to be to open the shop") — a wholly different method from `Vendor.CheckClose`, which reads `UseRadius` directly with no fallback of its own (`UseRadius` is `float?`; a nullable comparison against a null right operand is always `false`, so `CheckClose` never closes at all on an unauthored radius). `EnforceRange`'s own code comment carried the same mis-attribution and, worse, actually APPLIED that mis-borrowed 0.6f as its fallback; it now passes the raw authored `UseRadius` with no fallback of any kind (0 when absent/unauthored, matching retail's own memset-zero `PublicWeenieDesc::_useRadius` default — a plain `float` field, `acclient.h:37181`, no sentinel). Retail's own behavior for a radius-0 handler is exactly this: close on the very first nonzero-distance check. **Extension (fix 5):** the watcher reads the SERVER-ECHOED ACCEPTED position snapshot (`RuntimeEntityRecord.Snapshot.Position`), sampled once per advanced frame at the post-network-command-phase, not retail's continuous live-pose push (retail's own client simulates and renders every entity's pose every frame; `CPlayerSystem`'s range handler reads that live pose, never a periodically-echoed one). Between accepted-position updates the watcher's distance measurement is therefore up to one update-interval stale. The one BLIND WINDOW this staleness could open into a wrong in/out-of-range verdict — an in-session portal/teleport, where the player's and vendor's position snapshots can briefly sit in DIFFERENT landblock coordinate frames mid-transit — is closed unconditionally by this same review's fix 1b (`RuntimeWorldTransitState.HasPendingTeleportStart`/`IsTeleportActive` short-circuit the whole distance computation before it runs, closing the session instead of measuring across the transit), so the staleness itself never reaches that particular failure mode; it remains recorded here as a standing precision gap for the window fix 1b does NOT cover (ordinary out-of-transit movement between the same-generation position updates a slow network tick can leave briefly stale). **Original text:** The client-local vendor-panel distance watcher closes on PLAIN 3D center-to-center distance instead of retail/ACE's CYLINDER-GAP distance (both objects' own collision radius and height subtracted from the center distance before comparing to `UseRadius`). Retail: `gmVendorUI::OpenVendor` registers `CPlayerSystem::RegisterObjectRangeHandler` keyed to the vendor's own `PublicWeenieDesc._useRadius`; ACE's server-side belt-and-suspenders `Vendor.CheckClose` closes on `GetCylinderDistance(lastPlayer) > UseRadius`, i.e. `Position::cylinder_distance`/`Physics.Common.Position.CylinderDistance` with each side's real `GetRadius()`/`GetHeight()`. **NARROWED 2026-08-08 (vendor-verify gate): the watcher now measures retail's cylinder-gap via the ResolveObjectTableHost radii — the plain-center shortcut was self-closing sessions inside the walk-to-use acceptance band (opened at 4.29 m center vs authored radius 3, closed same frame). Residuals: heights pass 0, unresolvable hosts degrade to center distance (close-early only).** | `src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs` (`EnforceRange`) | `AcDream.Runtime` does not resolve a live per-entity collision radius/height for an arbitrary NPC outside the App-layer's Setup-cylinder resolver (`WorldSelectionQuery`'s `_setupCylinder`, App-only — out of Runtime's reach per the Core-structure rules, and `PhysicsBody`/`RuntimeEntityRecord` carry no radius/height field). Plain center distance is a well-defined, non-degenerate substitute (using `ObjectRangeMath.ObjectsInRange`'s existing `useRadii: false` branch rather than inventing a new metric) for a CLIENT-LOCAL UI convenience that never touches the wire or any authoritative state — closing the panel is not gated by, nor gates, anything server-visible. Reading the accepted-position snapshot rather than a continuously-integrated live pose is the same "Runtime has no live render-side pose, only the last accepted wire snapshot" constraint every other Runtime-side distance query in this codebase already accepts. | The panel can close up to (player radius + vendor radius) sooner than exact retail — typically well under a meter for a two-legged NPC — so a player standing exactly at the boundary of a large-radius vendor's `UseRadius` may see the panel close slightly earlier than retail would. No effect on any transaction, wire message, or authoritative state (Slice 6's buy/sell owns those). Retiring the cylinder-gap half requires a Runtime-owned per-entity collision radius/height source, which does not exist today; retiring the staleness half requires a continuously-updated live-pose source Runtime does not keep either. | `CPlayerSystem::RegisterObjectRangeHandler` pc:203677/0x004C4C34; `gmVendorUI::OnObjectRangeExit` pc:199486/0x004C02F0; ACE `Vendor.CheckClose`/`GetCylinderDistance` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367`) — a SEPARATE method, `WorldObject.IsWithinUseRadiusOf` (`WorldObject_Use.cs:44-52`), owns the unrelated `?? 0.6f` approach-check fallback; `acclient.h:37181` (`float _useRadius`, plain memset-zero field, no sentinel); `docs/research/2026-08-08-slice5-vendor-browse-research.md` §A.3/§B.1/§B.2 | diff --git a/docs/plans/2026-08-10-options-panel-campaign.md b/docs/plans/2026-08-10-options-panel-campaign.md index 5764fefd..c0f186d3 100644 --- a/docs/plans/2026-08-10-options-panel-campaign.md +++ b/docs/plans/2026-08-10-options-panel-campaign.md @@ -178,7 +178,9 @@ byte-exact; both adapters share one code path for the local write. `0x2E` (struct array `{0x30 button, 0x31 page, 0x32 isDefault}`) and ListBox template-list property `0x64` (entries `{0x63 layout DID, 0x62 element id}`) into `ElementInfo`. -- Create `src/AcDream.App/UI/UiTabControl.cs` (element Type 8): tab-button +- Create `src/AcDream.App/UI/UiTabPanel.cs` (element Type 8 — retail + `UIElement_Panel`; renamed from this plan's original `UiTabControl` at the + OP2 rework, and a dormant `UiDatElement` subclass per AD-73): tab-button ↔ page-slot switching per lane A §5; default tab honoured. - Create `src/AcDream.App/UI/UiTemplateListBox.cs` (element Type 5 with authored template list): `AddItemFromTemplateList(index)` instantiates a @@ -415,8 +417,8 @@ before anything builds on them. | Slice | Status | Commit(s) | Review | Gate | |---|---|---|---|---| -| OP1 | CLOSED (residuals R1/R2 owed to a coordinator pass) | `86c0a7e0` + fixes `09029f9f` | dual APPROVE-WITH-FIXES (`2026-08-10-op1-review-{mechanism,blast}.md`) → re-review CLOSED (`2026-08-11-op1-rereview.md`) | automated only — n/a | -| OP2 | REWORK round 1 in flight | `df9c7a35` (REJECTED) | dual REJECT (`2026-08-11-op2-review-{mechanism,blast}.md`) | | +| OP1 | CLOSED | `86c0a7e0` + fixes `09029f9f` + residuals `6f48e341` | dual APPROVE-WITH-FIXES (`2026-08-10-op1-review-{mechanism,blast}.md`) → re-review CLOSED (`2026-08-11-op1-rereview.md`); residuals R1/R2/R3 landed | automated only — n/a | +| OP2 | CLOSED | `df9c7a35` (REJECTED) → rework `b236a442` → closure (this commit) | dual REJECT (`2026-08-11-op2-review-{mechanism,blast}.md`) → re-review blast CLOSED / mechanism REOPEN-on-one (`2026-08-11-op2-rereview-{mechanism,blast}.md`) → coordinator closure: AP-195 filed, AD-73 addendum, tooltip port, zero-children pin | automated only — n/a | | OP3 | — | | | | | OP4 | — | | | | | OP5 | — | | | | diff --git a/docs/research/2026-08-11-op2-rereview-blast.md b/docs/research/2026-08-11-op2-rereview-blast.md new file mode 100644 index 00000000..55378276 --- /dev/null +++ b/docs/research/2026-08-11-op2-rereview-blast.md @@ -0,0 +1,487 @@ +# OP2 rework re-review — regression / blast-radius lens + +**Rework under review:** `b236a442` "fix(ui): OP2 rework — dormant UiDatElement +subclasses, fixed Panel/CheckboxBitfield64 mechanism" +**Original (REJECTed):** `df9c7a35`; round-1 findings +`docs/research/2026-08-11-op2-review-blast.md` +**Reviewer lens:** what OP2 breaks OUTSIDE its own scope (Options panel). +**Date:** 2026-08-11 +**Verdict: CLOSED** — all three MUST-FIXes and all three SHOULD-FIXes are +discharged, verified against the committed data rather than against the commit +message. Four residuals, none blocking: 1 SHOULD-FIX (an unpinned premise), 3 +NOTEs. + +--- + +## 0. Executive summary + +The rework replaces "widen the factory arm and hope" with a **dormancy model**: +`UiDatElement` is unsealed (`src/AcDream.App/UI/Layout/UiDatElement.cs:43`), and +both behavioral widgets now derive from it — `UiTabPanel : UiDatElement` +(`src/AcDream.App/UI/UiTabPanel.cs:79`) and `UiTemplateListBox : UiDatElement` +(`src/AcDream.App/UI/UiTemplateListBox.cs:81`). The Type-8 and Type-5 factory +arms are unconditional again +(`src/AcDream.App/UI/Layout/DatWidgetFactory.cs:106`, `:119`), which is now +**safe rather than lucky**, because an un-activated instance inherits the exact +`UiDatElement` media draw, `ClickThrough = true` default, and `IUiDatStateful` +propagation the pre-OP2 fallback provided, and performs zero import-time work +(`UiTabPanel.cs:126-128`, the empty `OnChildrenAttached`). + +That is the right shape. It removes the class of defect instead of narrowing a +guard, and it does so without the "gate on the Options LayoutDesc ids" workaround +round 1 offered as one option. + +**All three MUST-FIXes verified closed against primary data**, not against the +commit message: + +- The five Type-8 elements are behaviorally identical to pre-OP2, and the six new + pins assert the **failure modes** (media file id non-zero, `ClickThrough` true, + `ActivePageElementId == 0`, child count 16, all children `Visible`), not just + widget class names. +- The ten Type-5 elements gain no viewport child. Verified independently: **all + 16 Type-5 elements across all 32 fixtures have zero children**, so the one + genuine remaining delta (`ConsumesDatChildren => true`) drops nothing today. +- All 32 fixtures were regenerated and committed. Verified by structural diff: + **all 1,102 nodes across all 32 files carry the five new fields, and the + regeneration changed no pre-existing field value in any of the 27 previously + stale fixtures** — zero drift, in all 27, not just the 5 spot-checks requested. + +**Two of round 1's own findings were factually wrong and the rework absorbed one +of them without correcting the record.** See §5. This matters because SHOULD-FIX +2's stated rationale (base-vs-incorporated id mismatch) is refuted by the raw dat +data, and MUST-FIX 1(d)'s "mitigating: it is luck, three of four panels don't +resolve" was the inverse of the truth — **every** tab-table id resolves as a +direct child, so `df9c7a35` was strictly worse than round 1 assessed. The +rework's dormancy fix is correct either way; only the reasoning under +SHOULD-FIX 2 needs correcting. + +--- + +## 1. Method + +- Parsed all **32** committed fixtures under + `tests/AcDream.App.Tests/UI/Layout/fixtures/` at `b236a442`, walked every + `ElementInfo` node (1,102 total), and recorded Type, child ids, raw dat + properties `0x2E`/`0x64`/`0x72`, `StateMedia`, and new-field presence. +- Structural deep-diff of every one of the 27 pre-existing fixtures, + `df9c7a35` → `b236a442`, with the five new field names stripped, to isolate + "did regeneration change anything OLD?" +- Cross-checked the raw `0x2E` struct members (`0x30`/`0x31`/`0x32`) against the + projected `TabTable` field, per element, to settle the base-vs-incorporated-id + question with data. +- Read the widget/factory/importer sources and every new or changed test. +- No build, no test run, no launch (read-only mandate). + +--- + +## 2. MUST-FIX closure + +### MUST-FIX 1 — five Type-8 elements re-classed — **CLOSED** + +Every one of the five is now behaviorally identical to pre-OP2. The mechanism is +inheritance, not per-element special-casing: `UiTabPanel` adds no `OnDraw` +override, no `ClickThrough` write, no `ConsumesDatChildren` override, and its +`IUiChildrenAttachedListener.OnChildrenAttached` body is empty +(`UiTabPanel.cs:126-128`). + +| Element | Round-1 defect | Status | Evidence | +|---|---|---|---| +| vendor `0x1000008D` | lost DirectState backdrop fill | fixed | fixture `StateMedia[""] = (100687632, DrawMode 1)`; `UiDatElement` ctor picks `""` (no `DefaultStateName`, no `Normal`) → `ActiveMedia()` returns it → `UiDatElement.OnDraw` (`UiDatElement.cs:208`) draws it | +| character `0x10000227` | `ClickThrough` true→false; lost state propagation | fixed | `ClickThrough = true` set in `UiDatElement.cs:126`, never overwritten; `TrySetRetailState` is **non-virtual** (`UiDatElement.cs:86`) so a subclass cannot alter propagation | +| spellbook `0x100002A8` | same | fixed | same | +| vendor `0x100000B8` | same | fixed | same | +| combat `0x100000A2` | import-time takeover (8-entry table, no controller re-binder) | fixed | `OnChildrenAttached` is empty; `ActivateTabBehavior` has no caller anywhere in `src/` | + +**Do the new pins assert failure modes rather than class names?** Yes, on three +of the four axes, with one weaker: + +- **Media drawn** — `OP2ReworkBlastRadiusConformanceTests.cs:48-49`: + `(uint file, int _) = backdrop.ActiveMedia(); Assert.NotEqual(0u, file);` + This is the actual draw input, not a class name. ✅ +- **Click-through** — `:47`, `:67`, `:90`, `:106`, `:155`: `Assert.True(...ClickThrough)` + on all five Type-8 elements plus the representative Type-5. This is the exact + field `UiElement.HitTest` gates on. ✅ +- **No visibility flip / no takeover** — `:129-131`: + `Assert.Equal(0u, host.ActivePageElementId)`, `Assert.Equal(16, host.Children.Count)`, + `Assert.All(host.Children, child => Assert.True(child.Visible))`. That is the + observable failure mode (a hidden page), not a class name. ✅ +- **State propagation** — `:91`, `:107`: `Assert.IsAssignableFrom(root)`. + This is a *capability* assertion, not a behavior assertion: it does not build a + `PassToChildren` state and check a child received it. Acceptable-as-is because + `TrySetRetailState`/`ActiveRetailStateId` are non-virtual on `UiDatElement` + (`:86`, `:69`), so a subclass structurally cannot diverge — but it is the one + pin of the four that would still pass if the mechanism broke in the base class. + Recorded as NOTE 1, not a residual defect. + +Combat's specific claim ("no controller rebind") re-verified: grep of +`src/AcDream.App/UI/Layout/CombatUiController.cs` for `0x100000A2` / `0x100000A3` +returns nothing. + +### MUST-FIX 2 — ten Type-5 elements — **CLOSED** + +The viewport is now created lazily inside the `Viewport` property +(`UiTemplateListBox.cs:147-162`), reached only from `Scroll` and from a +*successful* `AddItemFromTemplateList` (`:182`). `DatWidgetFactory` leaves +`TemplateResolver` null (`:106` passes no resolver), and `AddItemFromTemplateList` +returns before touching `Viewport` when the resolver is null (`:175-176`) or the +index is out of range (`:174`). So no pre-existing Type-5 element can allocate or +attach the viewport. + +**Child structure verified identical to the old `UiDatElement` build** — with one +qualifier the class doc names and I confirmed: + +| | old `UiDatElement` | new dormant `UiTemplateListBox` | +|---|---|---| +| viewport child | none | none (lazy) ✅ | +| authored dat children | built and attached (`LayoutImporter.cs:129-136`) | **skipped** — `ConsumesDatChildren => true` (`UiTemplateListBox.cs:145`) | + +That second row is the only place the "behaviorally identical" claim is not +literally true. It is **harmless today, and I verified it directly rather than +taking the doc's word**: across all 32 fixtures, all 16 Type-5 elements have +**zero** `Children`. Incorporation (`LayoutImporter.Resolve` → `IncorporateChildren`, +`LayoutImporter.cs:352`) happens in the `ImportInfos` phase, so the committed +fixture child tables *are* the final production child tables — this is not a +fixture-vs-production gap. Nothing is dropped, and no id disappears from `byId`. + +The premise being unpinned by any test is SHOULD-FIX A below. + +**`EffectsUiControllerTests`' updated assertion is correct, not loosened.** The +diff changes `Assert.IsType` → `Assert.IsType` +(exact-type, xUnit `IsType` is not assignability), and the line immediately after +is unchanged and is the load-bearing one: +`UiItemList list = Assert.IsType(Assert.Single(listHost.Children));` +`Assert.Single` is exactly the assertion that fails if a viewport is injected +alongside the controller's own `UiItemList`. The pin got *stronger* in effect, +not weaker. + +### MUST-FIX 3 — harness blindness — **CLOSED** + +Three independent checks, all measured: + +1. **All 32 fixtures regenerated and committed.** `git show --stat b236a442` + lists all 32 fixture files as modified; 27 of them carry substantial line + counts (e.g. `character_2100002E.json` +1001, `combat_21000073.json` +741). +2. **All 27 previously-stale fixtures now carry the five new fields.** Verified + node-by-node, not file-by-file: **1,102 / 1,102 nodes across all 32 files + carry `TabTable`, `TemplateList`, `ScrollbarElementId`, `Outline`, and + `OutlineColor`. Zero nodes missing any of the five.** +3. **The regeneration introduced ONLY the expected field additions.** The task + asked for a spot-diff of at least 5 of the 27; I ran the structural deep-diff + on **all 27**, stripping only the five new field names and comparing every + remaining key/value recursively: + + ``` + character_2100002E.json : 0 non-new-field differences CLEAN + combat_21000073.json : 0 non-new-field differences CLEAN + spellbook_21000034.json : 0 non-new-field differences CLEAN + vendor_21000012_100000B7.json : 0 non-new-field differences CLEAN + effects_positive_2100001B.json : 0 non-new-field differences CLEAN + … and the other 22, all CLEAN (0 differences each) + ``` + + **No pre-existing value drifted.** The hazard the task named — "a changed + pre-existing value would mean the generator's output changed for OLD fields" — + did not occur anywhere. (`chat_2100006f.json`, the one fixture that already + carried `Outline`/`OutlineColor` from CH6a, is included in that sweep with the + same result on its other fields.) + +4. **The suite genuinely exercises the new-shape fixtures.** `ElementInfo`'s three + new members are public *fields* (`ElementReader.cs:192`, `:199`, `:207`) and + `FixtureLoader` deserializes with `IncludeFields = true` + (`FixtureLoader.cs:14-17`), so the fixture path now reads real projected + values. Concretely: `OP2ReworkBlastRadiusConformanceTests.cs:64` asserts + `host.Tabs.Count == 3` and `:127` asserts `== 8` — values that were empty + under the stale fixtures. The structural false negative is gone. + +5. **The reader itself is now covered independently of the fixtures.** The + remaining structural gap round 1 named — `ApplyCanonicalLegacyProjection` is + never called on the fixture path (`LayoutImporter.cs:465`, `ElementReader.cs:410` + are still its only call sites) — is addressed by five new tests in + `ElementReaderTests.cs` that drive `ApplyCanonicalLegacyProjection` directly + from synthetic raw property bags. A reader regression now fails a test even + without regeneration. That is the right seam. + +--- + +## 3. SHOULD-FIX closure + +### SHOULD-FIX 1 — unconditional viewport injection — **CLOSED** + +Lazy `Viewport` property (`UiTemplateListBox.cs:147-162`), pinned in both +directions: `OptionsPanelLayoutConformanceTests` asserts `Assert.Empty(listBox.Children)` +after a failed/never-attempted add, and `Assert.Single(listBox.Children)` after the +one successful add. `ConsumesDatChildren => true` also resolves round 1's +"authored children become siblings of the viewport" trap for OP3+ — authored +children are now never built for a Type-5 element, so the sibling-vs-row ambiguity +cannot arise. + +### SHOULD-FIX 2 — base-vs-incorporated tab-table ids — **CLOSED, on a corrected premise** + +`FindDirectChild` → `FindDescendant`, a pre-order DFS of the whole subtree +(`UiTabPanel.cs:208-219`), correctly cited to retail `GetChildRecursive`. + +**Round 1's stated rationale was factually wrong.** I read the raw `0x2E` struct +members straight out of the fixtures and compared them to the projected `TabTable` +and to the actual built child ids: + +| Host | raw `0x2E` page ids | projected `TabTable` | actual direct children | +|---|---|---|---| +| vendor `0x100000B8` | `0x100000BC`, `0x100000C4`, `0x100000CD` | identical | contains all three | +| combat `0x100000A2` | `0x100000AA` … `0x100005C3` | identical | contains all eight | +| character `0x10000227` | `0x1000022B`, `0x1000022C`, `0x10000539` | identical | contains all three | +| spellbook `0x100002A8` | `0x100002AC`, `0x100002AD` | identical | contains both | + +Round 1 reported vendor's table as naming `0x1000007C/0x10000084/0x1000008D` and +character's third entry as `0x100003F8→0x100003F9`. Neither appears anywhere in +the dat property. Since the deep-diff proves the raw properties are byte-identical +between `df9c7a35` and `b236a442`, this was a round-1 mis-read, not a data change. + +Measured consequence: **every tab-table id occurs exactly once, at depth 1, in +every one of the five Type-8 hosts** — so `FindDescendant` returns the same +element `FindDirectChild` did. No behavior change today. + +**Does the recursion introduce NEW ambiguity?** Yes, latently — and the codebase +already documents the duplicate-id hazard the task asked about +(`UiDatElement.cs:60-62`: *"footer state groups that appear once per tab page but +share the same dat id"*). Two facts: + +- Duplicate `DatElementId`s are **rampant** inside these very subtrees: character + `0x10000227`'s 133-node subtree has 39 duplicated ids (`0x10000244` ×6, + `0x1000023D` ×2, …); combat has four ids at ×8; spellbook has `0x10000328` ×13; + options has `0x100002CE/CF/D0` ×16. +- `FindDescendant` is **pre-order**: it fully recurses into child *n*'s subtree + before testing child *n+1*. So a deep match under an earlier sibling beats a + direct-child match on a later sibling — a strictly larger ambiguity surface + than `FindDirectChild` had. + +Safe today only because no tab-table id collides with a duplicated id. Recorded +as NOTE 2, with the OP3+ trigger named. + +### SHOULD-FIX 3 — silent no-resolve — **CLOSED** + +`UnresolvedEntries` (`UiTabPanel.cs:118`) plus a per-miss `Console.WriteLine` +carrying the host id, both entry ids, and which side missed +(`:154-157`). Detectable enough for this lens: it is a public, testable +collection, not only a log line, and `OptionsPanelLayoutConformanceTests` pins +`Assert.Empty(tabControl.UnresolvedEntries)` after activation. A future +regression that breaks resolution is now an assertable state change rather than a +silent no-op. + +--- + +## 4. New blast radius introduced by the rework + +Scanned the rework diff for what it *adds* to the exposure surface. + +**(a) Unsealing `UiDatElement` — no invariant becomes mutable by subclasses.** +`Info` is `protected readonly` (`UiDatElement.cs:57`), so the reference cannot be +reseated. The class's identity-carrying members are **non-virtual**: +`ElementId`, `ActiveRetailStateId` (`:69`), `TrySetRetailState` (`:86`), +`ActiveMedia` (`:150`). The only overridable surface a subclass inherits is +`UiElement`'s own (`OnDraw`, `OnHitTest`, `ConsumesDatChildren`, `OnEvent`, +`HandlesClick`) — and of those, exactly one is exercised +(`UiTemplateListBox.ConsumesDatChildren`, §2). `OnDraw` is +`protected override` and **not sealed**, so a future subclass could silently kill +authored-media drawing; neither current subclass does. NOTE 3. + +**(b) Unsealing widens every `is`/`as UiDatElement` site in the tree.** Nine +production sites now match Type-5/Type-8 widgets where they previously fell +through: `AppraisalUiController.cs:176`, `:224`; +`CharacterStatController.cs:1582`, `:1585`, `:1799`, `:1839`; +`SelectedObjectController.cs:152`; `CreatureAppraisalRows.cs:312`; +`RetailTabBinding.cs:21`; `SpellcastingUiController.cs:658`; +`WindowChromeController.cs:41`. Every one of them is additionally gated on a +specific `ElementId`/role, and I checked the ids involved +(`InscriptionBackgroundId = 0x10000137`, `SpellIconId = 0x1000015F`, +`FooterStateAId/BId = 0x10000240/41`, `OverlayId = 0x100001A0`) against the +complete Type-5/Type-8 id set — **no overlap**. The widening is also benign in +direction: the behavior these sites reach for (`OnClick`, `MediaVisible`, +`ClickThrough`) is inherited unchanged. Clean. + +The inverse — tests using exact-type `Assert.IsType` — is the +sharper edge, since xUnit's `IsType` rejects subclasses. Ten such sites exist; +`EffectsUiControllerTests.cs:136` was the one that needed updating and was +updated. The remaining nine target ids outside the Type-5/8 set. + +**(c) The factory arms being unconditional again.** Correct now, for the reason +the comments give. Verified there is no arm collision: the switch is a single +`info.Type switch` (`DatWidgetFactory.cs:76`), and the four `UIOption_*` class ids +(`0x10000036/37/38/44`) are distinct Type values from `5` and `8` — the +`0x10000044` template element `0x10000520` is Type `0x10000044`, not Type 5, so it +cannot be captured by the Type-5 arm. + +**(d) `BuildCheckboxBitfield64` deleted; `0x10000044u => new UiCheckboxBitfield64(info.TemplateList)` +(`DatWidgetFactory.cs:162`) no longer seeds `SpriteResolve`/`LabelFont`.** Zero +blast radius — class `0x10000044` occurs in exactly one fixture +(`options_2100002B.json`, element `0x10000520`), in a panel that is not yet +shipped. Rows now carry their own art via the resolved template. Noted for +completeness only. + +**(e) Fixture regeneration's effect on other conformance suites.** No test pins a +fixture value that regeneration changed, because **regeneration changed no +pre-existing value** (§2, MUST-FIX 3.3, all 27 files). The only way a suite could +have broken is by exact-type assertion on a re-classed widget — one site, updated. + +**(f) The `RuntimeCollisionReportingStateTests` allocation flake — not caused by +this rework, and it is exactly ISSUES #250's class.** +`tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs:2359-2384` +(`WarmedSteadyContactRefreshDoesNotAllocate`) has the precise shape #250 named as +its root cause: **one warmup call, then a 10,000-iteration loop written inline +inside the measured window**, asserted `Assert.Equal(0, allocated)`. #250's own +table lists that shape (`UiDatFontTests`: "1 call" warmup / "a 10,000-iteration +loop written inline") and diagnoses it as on-stack replacement compiling on the +measuring thread inside the window. + +The #250 fix moved **every strict-zero site in `AcDream.App.Tests`** onto +`tests/AcDream.App.Tests/ZeroAllocationProbe.cs`. It never swept the other test +assemblies: `AcDream.Runtime.Tests` still has three files with raw +`GC.GetAllocatedBytesForCurrentThread()` sites +(`Physics/RuntimeCollisionReportingStateTests.cs`, +`Physics/RuntimePhysicsStateTests.cs`, `Physics/RuntimeSetPositionStateTests.cs`, +plus `Gameplay/RuntimeLocalPlayerMovementStateTests.cs`), as do +`AcDream.Headless.Tests` and `AcDream.Core.Tests`. #250's own closing lesson +applies verbatim: *"'Not observed failing' only ever meant 'not yet observed'."* + +Could the rework have affected it? **No causal path.** `git show --stat b236a442` +touches only `docs/`, `src/AcDream.App/UI/**`, and `tests/AcDream.App.Tests/UI/**`. +Zero files under `src/AcDream.Runtime/` or `tests/AcDream.Runtime.Tests/`. Test +assemblies run in separate processes, so the larger App fixtures cannot raise GC +pressure inside the Runtime test process. + +--- + +## 5. Corrections to the round-1 report (record-keeping) + +Two round-1 claims are refuted by the committed data. Both are recorded here so +the next reader does not re-derive them: + +1. **"On 3 of the 4 panels the tab table's ids do not match the resolved child + ids, so `FindDirectChild` misses and `SwitchTo` is inert — mitigating, and it + is luck."** False. All 16 tab-table ids across the four pre-existing hosts + resolve as direct children (§3, SHOULD-FIX 2 table). `df9c7a35` was therefore + **worse** than round 1 assessed: all four panels would have had a live + import-time switcher, and combat — with no controller re-binder — would have + hidden seven of its eight stance pages on import. This strengthens, not + weakens, the case for the dormancy fix that landed. +2. **SHOULD-FIX 2's premise, "property `0x2E` carries base-layout ids that + `LayoutImporter.IncorporateChildren` remaps."** False; the raw struct members + equal the incorporated child ids exactly. The `FindDescendant` change is still + defensible on its own merits (retail uses `GetChildRecursive`, and OP3+ + cross-layout page mounts genuinely nest), but it was justified by a diagnosis + that does not hold, and it carries the real cost measured in §3. + +This is the `docs/research/2026-08-05-c4-closeout-handoff.md` pattern in +miniature — *inferring a fact you can observe*. The observation cost one script. + +--- + +## 6. Residuals + +### SHOULD-FIX A — `ConsumesDatChildren => true` rests on a measured-but-unpinned premise + +`UiTemplateListBox.cs:145` is the single remaining behavioral delta versus the +pre-OP2 `UiDatElement` fallback: authored dat children of a Type-5 element are no +longer built. The class doc states the premise correctly +(`UiTemplateListBox.cs:28-32`: "None of the ten pre-existing elements author real +dat children of their own"), and I verified it — all 16 Type-5 elements, all 32 +fixtures, zero children. + +**No test pins it.** Worse, the closest pin cannot distinguish the two outcomes: +`OP2ReworkBlastRadiusConformanceTests.cs:153` asserts +`Assert.Empty(host.Children)` on effects `0x10000123`, which passes both when no +viewport was injected (intended) *and* when authored children were silently +consumed (the failure). A future dat/importer change that gives any of the ten a +child would be dropped with the whole suite green. + +Suggested pin (cheap): assert `info.Children.Count == 0` on the `ElementInfo` side +for the ten ids, in the same test file, so the premise is asserted where it is +relied upon. Contrast the Type-8 side, which *does* have the right pin — +`Assert.Equal(16, host.Children.Count)` at `:130`. + +Also, only one of the ten Type-5 elements is pinned at all; the other nine +(`0x1000023D`, `0x10000532`, `0x10000123` negative, `0x10000149`, `0x1000032D`, +`0x10000335`, `0x10000174`, `0x10000464`) rely on class-level reasoning. That is +defensible for the dormancy properties, which are class-level; it is *not* +defensible for the child-count premise, which is per-element. + +### NOTE 1 — the state-propagation pin is a capability assertion + +`OP2ReworkBlastRadiusConformanceTests.cs:91`, `:107` assert +`IsAssignableFrom`, not that a `PassToChildren` state actually +reached a child. Sound in practice because `TrySetRetailState` is non-virtual +(`UiDatElement.cs:86`), so no subclass can diverge — but it is the one MUST-FIX-1 +axis whose pin would survive the mechanism breaking in the base class. + +### NOTE 2 — `FindDescendant` pre-order first-match in a duplicate-id tree + +`UiTabPanel.cs:208-219` recurses into an earlier sibling's entire subtree before +testing the next sibling, in trees where duplicate `DatElementId`s are common +(up to ×16 in `options_2100002B.json`). Harmless today — every tab id is unique +and at depth 1 — but the OP3+ trigger is concrete: the Options page slots +(`0x10000211`/`0x10000212`/`0x10000213`/`0x1000050C`) mount cross-layout content +that already re-uses ids present elsewhere in the same tree. If it bites, prefer +breadth-first, or search direct children first and fall back to descendants. + +Two smaller items in the same class, both unreachable today (nothing calls +`ActivateTabBehavior`): + +- `UiTabPanel.cs:161` binds the click even when only the *page* failed to resolve, + so the button would switch to a page that does not exist — and `SwitchTo` + (`:184-199`) would then hide every page and close every tab, i.e. a blank panel. +- Conversely, the new `ReadTabTable` zero-id filter (`ElementReader.cs:520-527`) + usefully protects `SwitchTo`'s `ActivePageElementId == 0` sentinel from ever + being a real page id. Good interaction, worth keeping in mind if that filter is + ever relaxed. + +### NOTE 3 — unsealing leaves `OnDraw` overridable + +`UiDatElement.OnDraw` (`:208`) is `protected override`, not sealed. A future +`UiDatElement` subclass that overrides it silently loses authored-media drawing — +which is precisely the `df9c7a35` vendor-backdrop defect, reachable again by a +different route. Sealing `OnDraw`, `TrySetRetailState`'s surface is already safe +(non-virtual), would close it. Also: `UiTabPanel` still implements +`IUiChildrenAttachedListener` with an empty body (`:126-128`) purely to document +"deliberately does nothing" — harmless, but it is a live interface registration +that a reader may mistake for an active hook. + +### NOTE 4 — blast-radius sweeps remain bounded by the 32 fixtures + +Both round 1 and this re-review measure the 27 layouts the fixture generator +covers (`RetailLayoutFixtureGenerator.cs:19-37`, `:95-184`). Production imports at +least a few LayoutDescs with no fixture — `0x21000008` +(`ExternalContainerController`, mounted at `RetailUiRuntime.cs:2177`/`:2223`), +`0x21000037` (shared UIItem catalog), `0x21000043` (vendor popup catalog), +`0x21000011` (SpewBox). With dormancy in place the only unmeasured risk in those +is narrow and specific: **a Type-5 element carrying authored dat children** +(Type-8 is now fully identical to the old fallback, with no `ConsumesDatChildren` +override, so it carries no residual risk at all). `0x21000008`'s two lists build +as `UiItemList` (`ExternalContainerController.cs:80-81` uses a `RequiredList` +helper), i.e. class `0x10000031`, not Type 5 — so the highest-traffic unfixtured +layout is clear. Recorded so the boundary of the claim is explicit, not because a +defect is suspected. + +--- + +## 7. Verdict + +**CLOSED.** + +The rework fixes the class rather than the instances: dormancy makes an +unactivated Type-8/Type-5 widget indistinguishable from the pre-OP2 +`UiDatElement` fallback by construction, so the unconditional factory arms are +now correct instead of accidentally survivable. All three MUST-FIXes are +discharged and — critically for a lens whose round-1 complaint was that the +harness could not see anything — the closure is verifiable from committed data: +1,102/1,102 fixture nodes carry the new fields, 27/27 regenerated fixtures show +zero drift in pre-existing values, all 16 Type-5 elements carry zero children, +and the new pins assert media file ids, `ClickThrough`, child counts, and page +visibility rather than widget class names. + +Residuals are one SHOULD-FIX (pin the `ConsumesDatChildren` premise where it is +relied upon) and four NOTEs, none of which changes shipped behavior. The +`RuntimeCollisionReportingStateTests` flake is ISSUES #250's class in an assembly +#250's fix never swept, with no causal path to this commit. + +The one thing worth carrying forward beyond OP2: round 1 got two facts wrong that +one fixture-parsing script settles, and one of them became the stated rationale +for a code change that shipped. §5 corrects the record. diff --git a/docs/research/2026-08-11-op2-rereview-mechanism.md b/docs/research/2026-08-11-op2-rereview-mechanism.md new file mode 100644 index 00000000..0ed2b2de --- /dev/null +++ b/docs/research/2026-08-11-op2-rereview-mechanism.md @@ -0,0 +1,376 @@ +# Campaign OP slice OP2 rework — MECHANISM-lens FOCUSED RE-REVIEW + +**Date:** 2026-08-11 +**Rework under review:** `b236a442` — "fix(ui): OP2 rework — dormant UiDatElement +subclasses, fixed Panel/CheckboxBitfield64 mechanism" +**Round-1 findings being closed:** `docs/research/2026-08-11-op2-review-mechanism.md` +(verdict REJECT on `df9c7a35`) +**Mode:** READ-ONLY. No build, no test run, no launch. Every claim is a quote from +committed source, a decode of a committed fixture, or an extract from +`docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +## VERDICT: **REOPEN — one MUST-FIX, narrowly scoped** + +All four *behavioural* MUST-FIXes and all five SHOULD-FIXes from round 1 are +genuinely closed, with regression tests that would actually catch a relapse. This is +**not** a redo — it is a one-item fix round. + +The one thing that fails is the tail of round-1 MUST-FIX 6, and it is the **same +failure mode that caused the round-1 REJECT, on the same element**: the commit +asserts a completeness claim about `0x10000520`'s authored data and about retail's +`Refresh` that the data and the decomp both refute. + +- `UiCheckboxBitfield64.cs:194-201`'s doc comment quotes + `UIOption_CheckboxBitfield64::Refresh @0x004859C0` as if the ANY-bit predicate were + the whole function. It is not. `Refresh` computes **both** an all-set and an any-set + result and uses them for **two different things** (§2.2 below). The all-set half + drives a per-row LED media swap between the element's two authored RenderSurface + DIDs — `P0x10000082 = 0x06004D17` and `P0x10000083 = 0x06004D19`, both still present + in the regenerated fixture, both still unread anywhere in `src/AcDream.App` + (verified by grep: zero hits outside one prose mention). +- That unported half has **no register row**. Round-1 MUST-FIX 6 asked for exactly + this ("Also missing rows for … the dropped `0x64`/`0x10000082`/`0x10000083` authored + data"). `0x64` is now consumed; the two DIDs are not, and the register gained only + AD-73 (tab dormancy) while TS-72 was retired. + +Fix = one AP row naming the unported `Refresh` media branch + the unported +`ResizeTo(GetWidth(), CalculatePaperSize(0,-1))` tail, and a corrected doc comment +that stops presenting a half-quoted `Refresh` as complete. The port itself belongs to +OP5 with the consumer; nothing is user-reachable today. + +--- + +## 1. Finding-by-finding closure check + +### 1.1 `UiTabPanel` — **CLOSED** (all five sub-checks) + +| Round-1 item | Rework | Evidence | +|---|---|---| +| MUST-FIX 5 — cite `UIElement_Panel`, not the fictional `UIElement_TabControl` | ✔ | `src/AcDream.App/UI/UiTabPanel.cs:8-21` names `UIElement_Panel::Create @0x0046C6B7`, `SetupTabPageHash @0x0046C2E0`, `Update @0x0046BD00`, `OpenTab @0x0046BE20`, `InqTabFromPage @0x0046BEB0`, `ListenToElementMessage @0x0046BF90`, and explicitly records the naming correction. `DatWidgetFactory.cs:110-119` carries the same correction at the factory arm. Class renamed `UiTabControl` → `UiTabPanel`; the old file is deleted. | +| Switching semantics preserved | ✔ | `UiTabPanel.cs:184-199` is byte-equivalent to the round-1 reading of `UIElement_Panel::Update @0x0046BD00`: exactly one page `Visible`, every tab button driven `Open`/`Closed` via `RetailTabBinding.SetOpen` → `RetailUiStateIds.Open`/`Closed` = retail `0xC`/`0xB`. Nothing in this commit touched `RetailTabBinding.cs` or `RetailUiStateIds`. | +| SHOULD-FIX 8 — recursive descendant resolution | ✔ | `FindDescendant` (`UiTabPanel.cs:208-219`) is a depth-first descendant walk used for **both** the button (`:149`) and the page (`:150`, `:191`, `:194`), matching retail's `GetChildRecursive` at `0x0046C4EE` / `0x0046BD00`. `FindDirectChild` is gone. | +| SHOULD-FIX 9 — no invented `_tabs[0]` fallback | ✔ | `UiTabPanel.cs:172-173` — `if (defaultEntry is { } def) SwitchTo(def.PageElementId);` with no `??=`. **Independently re-verified against the decomp this round:** `SetupTabPageHash` zeroes `m_OpenTabToken`/`m_OpenPageToken` at `0x0046C312`/`0x0046C318`, seeds `ebp_1 = 0` at `0x0046C31E`, only writes `ebp_1`/`var_3c` inside the `0x32`-true branch at `0x0046C4DF`/`0x0046C4E3`, and tail-calls `Update(this, var_3c, ebp_1)` at `0x0046C5EB`. With nothing authoring `0x32`, that is `Update(0,0)` against zeroed tokens, and `Update`'s guard `arg2 != m_OpenPageToken && arg3 != m_OpenTabToken` is false → **no switch**. Confirmed. | +| SHOULD-FIX 7 — reader skips malformed entries | ✔ | `ElementReader.cs:526` — `if (buttonId == 0u \|\| pageId == 0u) continue;` with the retail filter cited inline at `:520-525` (`InqProperty(0x30) @0x0046C3E5`, `InqProperty(0x31) @0x0046C3FC`). I re-read both sites: a missing `0x30` jumps `label_46c510` and a missing `0x31` jumps the same, both landing on `label_46c594` (next entry) without touching either hash. Match. | + +Round-1 SHOULD-FIX 3's "a silently-empty table and a wholly-unresolvable table are +indistinguishable" is also addressed: `UnresolvedEntries` (`:118`) plus a `[D.2b]` +diagnostic line (`:154-157`). + +Two retail behaviours in `SetupTabPageHash` remain unported and are now *newly* +visible because the rework reads that function properly — both inert on today's data, +both NOTE (§3). + +### 1.2 `UiCheckboxBitfield64` — **CLOSED on all three named sub-checks** + +**(a) Rows built from the authored template via `AddItemFromTemplateList(0)`.** ✔ +`UiCheckboxBitfield64.AddChild` (`:140-180`) resolves `Templates[0]` through +`TemplateResolver`, finds `0x10000219` by recursive descendant search +(`FindCheckboxRecursive`, `:182-192`), and stamps label/checked/click onto that +widget. The synthesized `ElementInfo{Type=1}` and the invented `RowHeight = 14f` are +**deleted**; `BuildCheckboxBitfield64` is deleted from the factory and the arm is now +`0x10000044u => new UiCheckboxBitfield64(info.TemplateList)` +(`DatWidgetFactory.cs:162`). Row geometry now comes from `row.Height` +(`:173-176`) — the authored 272×20. + +This matches retail `CreateChildren @0x00485DF0` as I re-read it this round: +`AddItemFromTemplateList(&this->vtable, 0, nullptr)` per row → `GetChildRecursive(row, +0x10000219)` → `DynamicCast(1)` → `SetAttribute_Int(cb, 0x10000084, i)` → +`SetStringInfo` → `SetTooltip`. + +**Fixture-verified independently** (I decoded `options_2100002B.json` myself, not the +commit message): `0x10000520` is `T=0x10000044`, 272×100, **zero** children, empty +`StateMedia`, and authors exactly +`TemplateList = [{0x2100002B, 0x10000521}]`; `0x10000521` is `T=3`, 272×20, one child +`0x10000219`. The conformance test pins that exact pair +(`OptionsPanelLayoutConformanceTests` `…Bitfield64Template_BuildsAsEmptyUiCheckboxBitfield64`). + +**(b) `IsSet` is retail's ANY-bit predicate.** ✔ `:200-201` — +`(CurrentLow & lowMask) != 0 || (CurrentHigh & highMask) != 0`. Matches +`Refresh @0x00485A11`: `ebx = ((eax_2 | ecx_2) != 0) ? 0 : 1` → +`SetAttribute_Bool(cb, 0xE, ebx == 0)`. + +**(c) A multi-bit test that actually discriminates.** ✔ +`UiCheckboxBitfield64_MultiBitMask_IsSet_UsesAnyBitNotAllBits` seeds current +`0x00000040` against Combat's real two-bit mask `0x00600040` and asserts +`row.Selected == true`. Under the old all-bits predicate `IsSet` returns false and +`Assert.True(row!.Selected)` fails — the test genuinely discriminates, unlike the +single-bit `Error` case it replaced (which is retained and now also pins that the row +resolves to `TemplateCheckboxElementId`). It further pins that clicking clears +**both** mask bits, i.e. the AND-NOT half. + +`ToggleRow` (`:209-224`) is unchanged and remains correct: `turnOn = !IsSet(...)` +derives from the bitfield, not from `Selected`, so a `ToggleBehavior` self-toggle on +the resolved `UiButton` cannot double-flip it. + +### 1.3 TS-72 retirement — **PARTIALLY CLOSED (the MUST-FIX)** + +The retirement itself is legitimate. TS-72's stated unknown was the click-toggle bit +math; that is now decomp-confirmed +(`ListenToElementMessage @0x00485AE0` → `BitUtils::SetBitsOnOrOff @0x004857D0`: +`arg3 != 0` → OR-in both halves, else AND-NOT both halves). The retirement note at +`docs/architecture/retail-divergence-register.md:350` states plainly that the toggle +half was always right and the checked-state half was wrong and is now fixed. Accurate, +and written per register convention (struck row retained with its original text; the +section header's active count moved 41 → 40). + +**What was swept:** the note ends "nothing about its own click/checked mechanism +remains genuinely unverified". Verified is not the same as ported. Reading `Refresh` +in full (§2.2) shows a decomp-confirmed, unported presentation half of the checked +mechanism, on the same element whose "authors nothing" claim caused round 1's REJECT, +with the two authored DIDs it consumes still sitting unread in the fixture. That gap +has no row anywhere in the register. + +AD-73 is correct as far as it goes but is scoped to `UiTabPanel` only (§1.4). + +### 1.4 The dormancy shape — **CLOSED**, with one undocumented shape change + +**Overrides cannot leak pre-activation.** I enumerated every member both subclasses +add or override: + +| | `UiTabPanel` | `UiTemplateListBox` | +|---|---|---| +| base | `UiDatElement` (`:79`) | `UiDatElement` (`:81`) | +| `OnDraw` | not overridden → `UiDatElement`'s authored-media draw | not overridden | +| `ClickThrough` | base ctor sets `true` (`UiDatElement.cs:138`) | same | +| `IUiDatStateful` | inherited unchanged | inherited unchanged | +| `ConsumesDatChildren` | **not** overridden → `false`, children recurse | **overridden `true`** (`:145`) ← the one change | +| `AddChild` | not overridden | not overridden | +| import-time hook | `OnChildrenAttached` is an **empty body** (`:126-128`) | none | +| side-effecting members | `ActivateTabBehavior()` / `SwitchTo()` — explicit calls only | `AddItemFromTemplateList()`; `Scroll` getter materialises the viewport (`:107`) | + +`UiDatElement` itself changed only `sealed` → `public class` (`:43`) and `_info` → +`protected readonly ElementInfo Info` (`:57`) plus mechanical renames. No behavioural +edit in the base. ✔ + +**The one shape change:** `UiTemplateListBox.ConsumesDatChildren => true` is *not* +identical to the pre-OP2 fallback, which recursed into dat children. I verified the +class doc's justifying claim myself by walking every committed fixture: **every** +Type-5 element in the corpus has zero authored children — +`character 0x1000023D` / `0x10000532`, `effects_positive`/`effects_negative +0x10000123`, `examine 0x10000149` / `0x10000335` / `0x1000032D`, +`mini_game 0x10000174`, `spellbook 0x10000464`, and the three Options ListBoxes +`0x100001FA` / `0x10000200` / `0x1000050D`. So the change is inert today and the claim +holds. It is a latent divergence (a future Type-5 that authors children loses them +silently), and it is not mentioned in AD-73 or anywhere in the register — NOTE, §3. + +**Tab-table entry counts survived the new `0x30`/`0x31` skip** — character 3, combat +8, spellbook 2, vendor 3, options 4, i.e. the same counts round 1 recorded. The filter +is inert on real data, exactly as retail's is. + +**AD-73 states the adaptation truthfully.** Register `:108`. I checked each claim: +- "Retail … unconditionally activate the authored default page for ANY instance that + carries a tab table" — correct, and I confirmed the trigger this round: + `SetupTabPageHash` has exactly one caller, `UIElement_Panel::OnSetAttribute + @0x0046C6DC`, fired when property `0x2E` is applied. Per-instance, unconditional. +- Oracle citations `SetupTabPageHash @0x0046C2E0`, `Update @0x0046BD00`, + `OpenTab @0x0046BE20` — all three verified present at those addresses in + `acclient_2013_pseudo_c.txt`. +- The four named hosts and their controller ownership match the fixture decode + (character `0x10000227` tab=3, spellbook `0x100002A8` tab=2, vendor `0x100000B8` + tab=3, combat `0x100000A2` tab=8, plus the tab-table-less vendor backdrop + `0x1000008D`). +- The Risk column names the real failure mode (a forgotten `ActivateTabBehavior()` + leaves every page slot visible at once, silently). Accurate. + +AD-73 covers only `UiTabPanel`. `UiTemplateListBox`'s dormancy (lazy viewport + +`ConsumesDatChildren`) rides on the same adaptation and is unregistered — NOTE, §3. + +### 1.5 Reader-level tests — **CLOSED** + +`tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs:354-481` adds five tests that +build a synthetic `ElementInfo`, stuff a raw `UiPropertyValue` into +`States[DirectStateId].Properties.Values[0x2E/0x64/0x72]`, and call +`ElementReader.ApplyCanonicalLegacyProjection(info)` directly. + +Would they catch what the fixture tests miss? **Yes, decisively.** The fixture tests +deserialize `ElementInfo.TabTable`/`TemplateList`/`ScrollbarElementId` as already- +projected JSON fields; `LayoutImporter.Build` never re-runs the projection at load, so +deleting the body of `ReadTabTable` would not fail one fixture test. These five drive +the reader functions themselves. Coverage: + +- `ReadTabTable_DecodesButtonPageDefaultInAuthoredOrder` — struct-member ids, authored + order, `0x32` present/absent. +- `ReadTabTable_SkipsEntriesMissingButtonOrPage` — the new retail filter, with a + well-formed entry *after* two malformed ones, so an early-`break` regression fails too. +- `ReadTemplateList_DecodesLayoutDidAndElementIdInAuthoredOrder` — `0x63`/`0x62` pair, + order. +- `…ScrollbarLinkage_DecodesEnumProperty` / `…AbsentPropertyStaysZero` — the `0x72` + value and its absent case. + +Gap (NOTE, not a miss of the round-1 finding): none of the five exercises +`ReadStructMemberId`'s `Enum`/`DataId`/`Integer` kind coercion — every test uses +`Enum`. A regression narrowing that coercion would still slip through. + +The blast-radius pins (`OP2ReworkBlastRadiusConformanceTests.cs`) are the other half +of the answer: six built-widget tests across the five pre-existing Type-8 elements and +one representative Type-5, asserting `BehaviorActive == false`, +`ActivePageElementId == 0`, `ClickThrough == true`, non-zero `ActiveMedia()` on the +vendor backdrop, 16 children still present and all `Visible` on combat, and +`Empty(Children)` + `ContentHeight == 0` on the effects list. Those are exactly the +properties the dormancy model rests on. + +--- + +## 2. New mechanism infidelity introduced or newly exposed by the rework + +### 2.1 **MUST-FIX** — `Refresh`'s all-set branch is unported, its two authored DIDs are unread, and neither the doc comment nor the register says so + +`UIOption_CheckboxBitfield64::Refresh @0x004859C0`, in full, per row: + +``` +lowHit = m_current.low & mask.low; // eax_2 @0x004859F9 +highHit = m_current.high & mask.high; // ecx_2 @0x004859FB +allSet = (lowHit == mask.low && highHit == mask.high); // var_d_1 @0x004859FF..0x00485A0A +ebx = ((lowHit | highHit) != 0) ? 0 : 1; // anySet @0x00485A11 + +row = UIElement_ListBox::GetItem(this, i); // @0x00485A21 +cb = UIElement::GetChild(row, 0x10000219); // @0x00485A35 +UIElement::SetAttribute_Bool(cb, 0xE, ebx == 0); // checked <=> ANY set ← ported ✔ + +if (ebx == 0) { // @0x00485A51 — only when ANY set + led = UIElement::GetFirstChildElement(cb); // @0x00485A5A (0x10000328) + if (led) { + if (allSet && GetAttribute_DataID(this, 0x10000082, &v)) // @0x00485A69/0x00485A7C + UIElement::SetMediaImageForState(led, v, 1, 6); // @0x00485AA7 + else if (GetAttribute_DataID(this, 0x10000083, &v)) // @0x00485A9A + UIElement::SetMediaImageForState(led, v, 1, 6); + } +} +``` + +Retail therefore renders **three** row states, not two: unchecked, checked-partial +(`0x10000083`), checked-complete (`0x10000082`). Eight of the thirteen Chat filter +masks are multi-bit (Gameplay `0x83912021`, Combat `0x00600040`, Magic `0x00020080`, +AreaSpeech `0x00001004`, Tells `0x00000018`, Allegiance `0x00040C00`, …), so the +partial state is reachable in ordinary play, not a corner case. + +Evidence of the gap: +- `src/AcDream.App/UI/UiCheckboxBitfield64.cs:194-201` quotes `Refresh` as + `checked = (lowHit | highHit) != 0` and stops there — the `allSet` computation and + the entire media branch are absent from both the code and the citation. +- `RefreshRowVisuals` (`:226-230`) writes only `row.Toggle.Selected`. +- `grep -i '0x10000082\|0x10000083\|10000328\|0x10000084' src/AcDream.App/**/*.cs` + returns exactly **one** hit, a prose mention at `UiCheckboxBitfield64.cs:28`. The two + DIDs are read by no code path; `ElementReader` does not project them onto + `ElementInfo` either. +- They are unambiguously authored: decoded from `options_2100002B.json`, element + `0x10000520` DirectState carries `P0x10000082 = DataID(0x06004D17)` and + `P0x10000083 = DataID(0x06004D19)` alongside `P0x64`. +- `docs/architecture/retail-divergence-register.md` gained AD-73 only. No AP/TS row + names this. + +Adjacent and in the same fix: retail's `CreateChildren` tail is +`ResizeTo(GetWidth(), CalculatePaperSize(0, -1))` at `0x00485E87`/`0x00485E97` — the +block sizes itself to its rows because it is a non-scrolling `UIElement_ListBox` +nested inside the Chat page's scroller. `UiCheckboxBitfield64` tracks `_contentHeight` +(`:53`, `:174-175`) but never applies it to `Height`, so the block stays at its +authored 272×100 while thirteen 20 px rows need 260 px. Also unported, also unrowed. + +**Required:** one AP row covering both (unported `Refresh` media branch + unported +`ResizeTo`/`CalculatePaperSize`), naming `0x10000082`/`0x10000083`/`0x10000328` and +pointing at OP5 as the consumer gate; and a corrected `IsSet` doc comment that quotes +`Refresh` completely instead of half. The port itself can land at OP5 with the +consumer — nothing user-reachable observes it today. + +### 2.2 SHOULD-FIX — `AddChild` drops retail's tooltip and per-row index stamp + +`CreateChildren @0x00485E50`/`@0x00485E65` calls `SetStringInfo(cb, siLabel)` **and** +`SetTooltip(cb, siTooltip)`; `@0x00485E3E` stamps `SetAttribute_Int(cb, 0x10000084, +i)`. acdream sets `checkbox.Label` (`:169`) and stores `tooltip` in the `Row` record +(`:178`) but never applies it; there is no settable tooltip on `UiElement` (only a +virtual `GetTooltipText()` returning null, `UiElement.cs:454`), so this needs a +one-line override or a settable property. The `0x10000084` index stamp is *correctly* +replaced by a mask closure (`:171`) — equivalent and better typed; call that out in +the doc rather than leaving it silently absent. + +### 2.3 SHOULD-FIX — the ListBox mechanism is now implemented twice, divergently + +`UIOption_CheckboxBitfield64` **is** a `UIElement_ListBox` in retail (it calls +`AddItemFromTemplateList`/`GetItem`/`CalculatePaperSize`/`ListenToElementMessage` on +itself; `PostInit` tail-calls `UIElement_Scrollable::PostInit`). The rework's class +doc argues against reusing `UiTemplateListBox` because "retail's block does not itself +scroll" — an assertion with no citation, and one the `CalculatePaperSize`/`ResizeTo` +tail partly contradicts (the block *does* do ListBox self-sizing). The result is two +independent row-stacking implementations that already differ: `UiTemplateListBox` +stacks into a lazily-created `UiScrollablePanel` +(`UiTemplateListBox.cs:172-187`), `UiCheckboxBitfield64` stacks directly onto itself +(`:173-176`). Not a defect today; it is the seam where §2.1's `ResizeTo` gap and any +future ListBox fix will diverge. Either reuse the class or record why in the register +alongside §2.1's row. + +--- + +## 3. NOTES + +1. **`UiTemplateListBox.ConsumesDatChildren => true`** (`:145`) is the one place the + dormant subclass is not byte-identical to the `UiDatElement` fallback. Inert today + (all ten pre-existing Type-5 elements have zero authored children — verified across + every committed fixture), unregistered, and silent if a future Type-5 authors + children. One sentence in AD-73 or its own row. +2. **`UiTemplateListBox.Scroll`** (`:107`) is a side-effecting getter — reading it + materialises and attaches the viewport, breaking dormancy without any explicit + activation call. `ContentHeight` (`:111`) and `LineHeight` (`:116-124`) are both + null-safe; `Scroll` is the odd one out. Nothing reads it today. +3. **AD-73 scopes dormancy to `UiTabPanel` only.** `UiTemplateListBox`'s lazy viewport + is the same adaptation against retail's always-scrollable ListBox and rides + uncovered. +4. **`SetupTabPageHash`'s early return is unported.** At `0x0046C4EE`/`0x0046C4F5` + retail does `GetChildRecursive(this, tabId)` and, on a miss, **returns outright** — + abandoning the remaining entries and never calling `Update`. `UiTabPanel` logs the + miss, keeps iterating, and still performs the default switch (`:149-173`). Inert on + today's data (every options entry resolves) and arguably the better diagnostic, but + it is a divergence the round-1 SHOULD-FIX 3 response introduced. +5. **`tab != page` and `MasterPropertyId == 0x2F` filters still unimplemented** + (`0x0046C3A6`, `0x0046C46B`). Retail adds the hash entries either way and only + gates the default-flag read and `SetMouseVisible(1)` on `tab != page`. Harmless + today. Carried from round-1 NOTE 12. +6. **`SetMouseVisible(1)` per tab button** (`0x0046C4FB`) and + **`BroadcastElementMessage(this, 0x2C, …)`** after each `Update` remain unported. + OP3 needs the second for research §3.6's apply/revert semantics. Carried from + round-1 NOTE 14. +7. **`0x71` (horizontal scrollbar) still unread**; `ScrollbarElementId` is specifically + the *vertical* link (`UIElement_Scrollable::OnSetAttribute @0x00474630`) and is + still not named as such. Carried from round-1 NOTE 13. +8. **`SaveCurrentValue`/`RestoreSavedValue`/`Changed`** (`@0x004856A0`–`@0x00485700`) + are unported on `UiCheckboxBitfield64` — the Apply/Cancel triad OP5 needs. Not + introduced here; `SetDefaultValue`/`RestoreDefaultValue` shipped in OP2 as-is. +9. **`SpriteResolve` and `LabelFont` are now dead** on `UiCheckboxBitfield64` — the + factory no longer sets `SpriteResolve` (the row subtree carries its own resolver) + and `LabelFont`'s own doc says it is unused (`:80-82`). Harmless; delete at OP5. +10. **The campaign plan is stale.** + `docs/plans/2026-08-10-options-panel-campaign.md:181` still says "Create + `src/AcDream.App/UI/UiTabControl.cs`" — the exact fictional-class name this round + corrected — and still cites "lane A §5" for the switching contract when the tab + table is §1.3 and its host §1.4 (round-1 NOTE 18, unaddressed). OP3/OP8 will read + that line. +11. **Reader tests use only `Enum`-kind struct members**, so `ReadStructMemberId`'s + `Enum`/`DataId`/`Integer` coercion tolerance is unpinned. +12. **`IUiChildrenAttachedListener` now has exactly one implementer with an empty + body** (`UiTabPanel.cs:126-128`). Still the right hook for OP3's activation + plumbing; just no longer load-bearing in this commit. +13. Research §1.5's "`0x10000521` … UNKNOWN consumer" / §10.5 U10 are now closed by + the fixture + `CreateChildren`, and the research doc still says UNKNOWN. Carried + from round-1 NOTE 17. + +--- + +## 4. What I re-derived independently this round + +Everything below was read from primary source, not taken from the commit message: + +| Claim | Source | +|---|---| +| `SetupTabPageHash` has exactly one caller, `OnSetAttribute` on property `0x2E` | `acclient_2013_pseudo_c.txt` `0x0046C6C0`–`0x0046C6DC` (grep: 2 hits total for the symbol) | +| Zeroed tokens + `Update(0,0)` ⇒ no default switch | `0x0046C312`, `0x0046C318`, `0x0046C31E`, `0x0046C4DF`, `0x0046C5EB`; `Update` guard `0x0046BD00` | +| Missing `0x30`/`0x31` skips the entry | `0x0046C3E5`, `0x0046C3FC` → `label_46c510` → `label_46c594` | +| `Refresh` computes all-set AND any-set, and uses all-set for the LED media swap | `0x004859F9`–`0x00485AA7` (full function read) | +| `SetBitsOnOrOff` = OR-on / AND-NOT-off | `0x004857D0`–`0x0048580B` | +| `CreateChildren` = per-row `AddItemFromTemplateList(0)` + `GetChildRecursive(0x10000219)` + index/label/tooltip + `ResizeTo(CalculatePaperSize)` | `0x00485DF0`–`0x00485E97` | +| `0x10000520` authors `P0x64 = [{0x2100002B, 0x10000521}]`, `P0x10000082 = 0x06004D17`, `P0x10000083 = 0x06004D19`, zero children, empty StateMedia | decode of committed `options_2100002B.json` | +| `0x10000521` is `T=3`, 272×20, one child `0x10000219` | same | +| Every Type-5 element in every committed fixture has zero authored children | walk of all 32 committed fixtures | +| Tab-table entry counts unchanged post-regeneration (character 3 / combat 8 / spellbook 2 / vendor 3 / options 4) | same walk | +| Vendor `0x1000008D` is `T=8` with no tab table (the backdrop) | same walk | + +Fixture decodes used a throwaway reader in the scratchpad. Nothing in the repository +was modified except this document. diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 6a01f99a..ff189f0d 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -88,6 +88,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Label color (default white). public Vector4 LabelColor { get; set; } = Vector4.One; + /// Settable tooltip, surfaced through the shared + /// hover pipeline (same pattern as + /// ). Retail option rows stamp theirs via + /// UIElement::SetTooltip during row construction + /// (e.g. UIOption_CheckboxBitfield64::CreateChildren @0x00485DF0). + public string? TooltipText { get; set; } + + /// + public override string? GetTooltipText() => + string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + /// Retail LayoutDesc property 0x21 (two-pass glyph outline, /// UIElement_Text::SetOutline @0x0046a81c). Seeded by DatWidgetFactory /// from the element's effective-default state, same as diff --git a/src/AcDream.App/UI/UiCheckboxBitfield64.cs b/src/AcDream.App/UI/UiCheckboxBitfield64.cs index fcb28faf..b981805c 100644 --- a/src/AcDream.App/UI/UiCheckboxBitfield64.cs +++ b/src/AcDream.App/UI/UiCheckboxBitfield64.cs @@ -167,6 +167,13 @@ public sealed class UiCheckboxBitfield64 : UiPanel } checkbox.Label = label; + // Retail CreateChildren @0x00485DF0 stamps the row tooltip via + // SetTooltip (OP2 re-review §2.2). Its @0x00485E3E companion — the + // SetAttribute_Int(cb, 0x10000084, i) row-INDEX stamp retail later + // reads back to find which row a click meant — is deliberately + // replaced by the typed mask closure below: equivalent routing, + // without a stringly attribute round-trip. + checkbox.TooltipText = tooltip; checkbox.Selected = IsSet(lowMask, highMask); checkbox.OnClick = () => ToggleRow(lowMask, highMask, checkbox); @@ -195,7 +202,11 @@ public sealed class UiCheckboxBitfield64 : UiPanel /// Retail UIOption_CheckboxBitfield64::Refresh @0x004859C0: a row is checked /// when ANY bit of its mask is set in the current value — NOT when every bit is /// set. lowHit = current.low & mask.low; highHit = current.high & - /// mask.high; checked = (lowHit | highHit) != 0. + /// mask.high; checked = (lowHit | highHit) != 0. This is HALF of Refresh: + /// the same pass also computes the ALL-set predicate to swap each row's LED + /// media between the two authored surfaces (P0x10000082 = 0x06004D17 / + /// P0x10000083 = 0x06004D19) — not ported yet, register row AP-195, + /// due with the Chat tab (OP5) alongside the ListBox self-sizing tail. /// private bool IsSet(ulong lowMask, ulong highMask) => (CurrentLow & lowMask) != 0 || (CurrentHigh & highMask) != 0; diff --git a/tests/AcDream.App.Tests/UI/Layout/OP2ReworkBlastRadiusConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/OP2ReworkBlastRadiusConformanceTests.cs index c8f45d05..c35e88ec 100644 --- a/tests/AcDream.App.Tests/UI/Layout/OP2ReworkBlastRadiusConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/OP2ReworkBlastRadiusConformanceTests.cs @@ -1,3 +1,5 @@ +using System.IO; +using System.Text.Json; using AcDream.App.UI; using AcDream.App.UI.Layout; @@ -154,4 +156,51 @@ public class OP2ReworkBlastRadiusConformanceTests Assert.Equal(0, host.ContentHeight); Assert.True(host.ClickThrough); } + + /// + /// AD-73 addendum (OP2 re-review closure, 2026-08-11): + /// is true where the + /// pre-rework generic fallback's was not. That delta drops nothing ONLY while no + /// authored Type-5 element carries children — a premise the re-review verified by + /// hand across all 32 fixtures. This test turns that manual sweep into a build + /// gate: if a future DAT regeneration surfaces a Type-5 element WITH authored + /// children, this fails loudly instead of the children silently vanishing. + /// + [Fact] + public void EveryAuthoredType5Element_HasZeroChildren_AcrossAllFixtures() + { + string fixturesDir = Path.Combine( + AppContext.BaseDirectory, "UI", "Layout", "fixtures"); + string[] fixtures = Directory.GetFiles(fixturesDir, "*.json"); + Assert.NotEmpty(fixtures); + + var opts = new JsonSerializerOptions { IncludeFields = true }; + int type5Seen = 0; + foreach (string file in fixtures) + { + var root = JsonSerializer.Deserialize( + File.ReadAllText(file), opts); + Assert.NotNull(root); + Walk(root!, file); + } + + // The premise is only meaningful if the sweep actually saw Type-5 + // elements (16 across the current fixture set). + Assert.True(type5Seen >= 10, $"sweep saw only {type5Seen} Type-5 elements"); + + void Walk(ElementInfo node, string file) + { + if (node.Type == 5u) + { + type5Seen++; + Assert.True( + node.Children.Count == 0, + $"{Path.GetFileName(file)}: Type-5 element 0x{node.Id:X8} authors " + + $"{node.Children.Count} children — UiTemplateListBox.ConsumesDatChildren " + + "would drop them; see AD-73's addendum."); + } + foreach (ElementInfo child in node.Children) + Walk(child, file); + } + } }