diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b842aa53..83fc2af5 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -208,7 +208,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 161 active rows (AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); 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) — 160 active rows (AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); 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 @@ -395,7 +395,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-139 | **Filed 2026-08-04 (Bug B).** The remote tick clears its InterpolationManager queue on the LANDING edge — retail’s own `set_on_walkable(1)` transition, the same edge HitGround fires from. Retail has no such clear on a ground or contact edge: its only queue teardown outside a completed walk is `PositionManager::StopInterpolating` from `CPhysicsObj::teleport_hook` @0x00514EFD and the `InterpolationManager::UseTime` @0x00555f20 stall/autonomy blips. The clear is carried over unchanged in intent from the deleted hand-rolled landing block (#184, 2026-07-07), which hung it on a hand-rolled `Airborne && IsOnGround && Velocity.Z <= 0` test that also fired on a steep (non-walkable) contact; Bug B re-derived the edge without changing the behaviour it was written for | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (the SetPositionInternal commit block); the packet-side twin lives in `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the player-remote landing snap) | A contact-free arc never enqueues — route 4a's airborne no-op writes nothing at all — so anything still queued when the body lands is a pre-arc waypoint, and the first catch-up after touchdown would otherwise walk the body backward toward it | A remote that regains contact while a legitimately fresh waypoint is queued loses one correction and re-acquires it on the next accepted Position (~5-10 Hz). A body that repeatedly loses and regains contact (a bounce chain down a rough face) clears the queue once per bounce. Retire when the arc itself feeds the queue, at which point the pre-arc waypoints are no longer stale | `CPhysicsObj::teleport_hook @ 0x00514ED0` (`StopInterpolating` @0x00514EFD); `InterpolationManager::UseTime @ 0x00555f20`; `CPhysicsObj::SetPositionInternal @ 0x00515330` | | AP-181 | **Filed 2026-08-09 (Campaign CH slice CH3, side-channel gate); corrected 2026-08-09 at the CH3 Opus review (S6) — the original text named only the spam throttle and wrongly credited `RouteLegacyChannel` with porting gates it has no code for.** Retail's `SendTurbineChat @0x0057db10` runs TWO local pre-send refusals acdream has no port for, in this order: `IsMessageSafe(text)` first (a silent drop — no wire send, no local text at all), then, only if that passes, the per-account spam throttle `IsMessageSpam()` (→ "You must wait %ds before communicating again!"). acdream's `TurbineChatMembershipGate`/`RouteTurbineChat` port the Turbine-unavailable and Hear-option gates that run BEFORE both checks in retail's own function (§4.2) and stop there — neither `IsMessageSafe` nor `IsMessageSpam` exists anywhere in acdream. `RouteLegacyChannel` is the unrelated legacy 0x0147 `ChatChannel` pipeline and has no equivalent of either check in retail OR acdream — it was never the site these two gates belonged to. | `src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs`; `src/AcDream.App/Net/LiveSessionCommandRouter.cs` (`RouteTurbineChat`) | The user's target server (local ACE) leaves `chat_requires_account_15days`/`chat_requires_player_level` etc. at their disabled defaults (research doc §3.6) and has no observed rate-limit or unsafe-content complaint; porting a client-side throttle/safety check with no server-side counterpart to validate against risks inventing a threshold retail didn't use. | A future connected gate against a server that DOES rate-limit chat, or a deliberately unsafe test string, would see every send attempted rather than refused after the first — cosmetic only, since ACE's own server-side handling (if any) still governs what actually reaches other players. | `ClientCommunicationSystem::SendTurbineChat @0x0057db10` (`IsMessageSafe`/`IsMessageSpam` branches); research doc `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §4.2 | | AP-182 | **Filed 2026-08-09 (Campaign CH slice CH4); corrected 2026-08-09 at the CH4 REJECT-review (nit 11).** `@title ` is wired to a pure no-op — `LiveSessionRuntimeFactory`'s `SetChatTitle` binding is `_ => { }`; the requested title is neither stored nor consumed anywhere (the original filing's "stores the value locally" claim was false). This matches retail's own silent success (no confirmation text was recovered at the `DoTitle` success site, so a no-visible-effect accept is exactly as faithful as a stored-but-unread value would be). Also omitted: `DoTitle`'s three local failure messages — no title given, "You must provide a new title for the window."; length over 99 characters, "Window title length cannot exceed 100 characters."; and wrong source window (`m_idCurrentCommandSource` 1 or 8), "This command must be issued from a popup chat window." — acdream's catalog validator (`ClientCommandId.SetChatTitle`, `AnyArguments`) accepts any argument shape and never raises any of the three. `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`SetChatTitle`) | Retail's chat window presumably re-renders its title bar text; acdream's chat window has no title bar at all under the current retained-UI import, so there is nothing to visually diverge from yet | Once a titled chat-window chrome is built, `@title` needs to be re-wired to it — today it is a pure no-op, and the three failure messages above are silently absent | `ClientCommunicationSystem::DoTitle @ 0x0057A640` | -| AP-185 | **Filed 2026-08-10 (Campaign CH slice CH6a — retail chat-window layout + 8-grip resize).** The main chat window's 8 cosmetic `_Locked` border-art twins (`0x10000693`-`0x1000069A`) are retail's `PlayerModule::LockUI`-driven alternate skin — `gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0` swaps them in for the 8 live Resizebar/Dragbar grips (`0x1000069B`-`0x100006A2`) when the UI is locked, and swaps them back out when unlocked. `ChatWindowController.Bind` always hides the twins and always shows the live set — i.e. it renders only retail's UNLOCKED skin, regardless of `UiRoot.UiLocked`. `src/AcDream.App/UI/Layout/ChatWindowController.cs` (`LockedTwinIds`) | `UiRoot.UiLocked` already gates the underlying move/resize INTERACTION generically and correctly in both states (locked ⇒ no move, no resize, regardless of which border art is drawn); the two art sets occupy identical rects, so always showing the interactive-grip skin is a cosmetic simplification, not a functional one, and the default matches `UiLocked`'s own `false` default | A user who locks the UI (`PlayerModule::LockUI`) sees the interactive-grip chat-window border art unchanged instead of retail's inert locked variant — cosmetic only; the window still correctly refuses to move or resize while locked | `gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0`; `PlayerModule::LockUI`; `docs/research/2026-08-09-chat-retail-window-shell.md` §1.6 | | AP-187 | **Filed 2026-08-10 (Campaign CH slice CH6b — floating chat windows). BROADENED 2026-08-11 at Campaign OP slice OP5 (Chat tab): the divergence now covers the MAIN chat window's filter too (`ChatSettings.ChatWindowMainFilter`), and the write path is no longer mount-time-seed-only — the retail Options panel's Chat tab (`ChatOptionsPageController`, five `UiCheckboxBitfield64` blocks) is now a LIVE editing surface for all five windows' filters, writing `ChatWindowState.SetFilter` directly and persisting on every change via `RetailUiRuntime.SaveChatWindowFilters`, closing that method's own former "worth tightening to auto-save-on-change once a live settings surface exists" note.** The five chat windows' (main + four floating) text-type filters (`AcDream.Core.Chat.ChatWindowState`, retail's `0x1000007F` per-window option) persist only in local `settings.json` (`ChatSettings.ChatWindowMainFilter`/`ChatWindow1Filter`..`ChatWindow4Filter`, `SettingsStore.LoadChat`/`SaveChat`). Retail's authoritative store for this same data is the per-window option array (`0x1000008C`) packed inside the character-scoped `GameplayOptions` blob, which ACE stores and echoes as opaque bytes without parsing (window-shell research doc §4.1/§4.4); acdream has no reader or writer for that blob (CH3 already deleted one malformed attempt at the outbound `SetCharacterOptions 0x01A1` builder — `SocialActions.cs`). Geometry and open/visible state for these same windows do NOT need a row of their own: they persist through the pre-existing generic `RetailWindowLayoutPersistence` path (X/Y/W/H/visible/collapsed/maximized per window name), which is retail's OWN local-file mechanism too (`gmGamePlayUI::SaveScreenLayout`/`LoadScreenLayout`, window-shell research doc §4.3) — only the filter mask lacks any such local-file precedent in retail and is acdream's own addition to make the feature usable before a `0x1000008C` wire slice lands. `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs` (`LoadChat`/`SaveChat`/`BuildChatObject`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountChat`, `MountFloatingChatWindows`, `SaveChatWindowFilters`); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` | CH6a/CH6b's own port-shape recommendation (window-shell research doc §6.1) explicitly chose local persistence first and deferred the `0x1000008B`/`0x1000008C` wire to a dedicated CH6f slice, citing CH3's deleted malformed builder as the reason not to rush it | A character's floating-window filter customization does not travel to a different acdream install, and would not round-trip through a retail client sharing the same character (retail would see acdream's local-only values as unset, falling back to its own `PostInit` defaults) — cosmetic/preference-only, no gameplay effect | `PlayerModule::GetChatOptionStructure @0x005D5300`; `PlayerModule::InqChatWindowOption/SetChatWindowOption @0x005D5540/:70`; `docs/research/2026-08-09-chat-retail-window-shell.md` §4.1/§4.4/§6.1; `docs/plans/2026-08-09-chat-parity-campaign.md` (CH6f row) | | AP-188 | **Filed 2026-08-10 (Campaign CH slice CH6b — floating chat windows).** A floating chat window's chat entry always sends on the `Say` channel (`FloatingChatWindowController.Bind`'s `OnSubmit` hardcodes `ChatChannelKind.Say`). The floaty LayoutDesc (`0x2100005B`) authors no talk-focus menu (window-shell research doc §2.2 — only the main window's layout has one, element `0x10000014`), so there is no visible channel picker on a floaty window either way, matching retail's authored UI exactly. What is UNVERIFIED is whether retail's actual SEND path for a floaty window's typed message reads a per-window channel or the single globally-current talk-focus channel/target the main window's menu (or the last-selected/last-speakable-target state `gmMainChatUI::UseTime @0x004CDB20` tracks) last set — if the latter, a real retail floaty window would send on whatever channel the player most recently picked from the MAIN window, not always `Say`. Confirming this requires tracing `gmCCommunicationSystem`'s send-command path from a floaty `ChatInterface` instance, not yet done. Filed as ISSUES.md #369. `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` (`Bind`, the `OnSubmit` wiring) | Building genuine cross-window shared-channel state (reading `ChatWindowController`'s private `_activeChannel` from four independent sibling controllers, or promoting it to a shared owner) is a real design decision outside this slice's explicit scope (task items 1-6 do not ask for cross-window channel sharing); `Say` is retail's own default channel and the safest fixed value absent confirmation | If retail's actual mechanism is "send on the currently-selected global channel," a user who selects e.g. Fellowship from the main window's talk-focus menu and then types into a floaty window would see it sent as Fellowship in retail but as Say in acdream — no data loss (the message still sends), only channel-selection mismatch | `gmMainChatUI::InitTalkFocusMenu @0x004CDC50`; `gmMainChatUI::UseTime @0x004CDB20`; `docs/research/2026-08-09-chat-retail-window-shell.md` §2.2 | | AP-189 | **Filed 2026-08-10 at the CH6a/b REJECT-review rework (SHOULD-FIX 5, `docs/research/2026-08-10-ch6ab-review-findings.md`).** Retail keeps a PER-`ChatInterface` `m_chatLog`, truncated at 10,000 lines (`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4711` → `TruncateChatLog`) — each of the five windows (main + 4 floaty) owns its OWN 10,000-line backlog, and a closed window keeps accumulating into its own log because `gmFloatyMainChatUI::SetVisible @0x004CE9B0` never unregisters the handler. acdream instead shares ONE canonical `ChatLog` capped at 500 entries (`RuntimeCommunicationState`'s ctor, `maximumChatEntries: 500`) with a 200-line display tail every window filters from (`InteractionRetainedUiComposition.cs:564`'s `displayLimit: 200` feeding `ChatVM.RecentLinesDetailed`; `ChatWindowState.ShouldDisplay` does the per-window filtering). The accumulate-while-closed and independent-per-window-scroll BEHAVIORS both fall out correctly from this shared-log shape, but the EFFECTIVE per-window scrollback DEPTH differs from retail's: a window whose filter accepts only a rare message type (e.g. a Fellowship-only floaty) sees only the fellowship lines that happen to still be inside the shared log's last 200-of-500 lines, not up to 10,000 like retail's own per-window log. `src/AcDream.Core/Chat/ChatLog.cs` (`_maxEntries`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:564` (`displayLimit: 200`); `src/AcDream.App/UI/Layout/ChatWindowController.cs`/`FloatingChatWindowController.cs` (`GetTranscriptLines`) | A single shared canonical log matches acdream's Slice-J "one canonical transcript, many filtered presentations" pattern and keeps memory bounded regardless of how many windows are open; 500 shared entries covers many minutes of typical mixed-channel play, and both retail-observable BEHAVIORS this row could have broken (closed-window accumulation, independent per-window scroll position) are reproduced correctly — only the numeric DEPTH ceiling differs | In a busy mixed-channel session (heavy General/Trade traffic), a rarely-used channel (Fellowship, a Turbine room) can scroll out of the shared 500-entry window long before a floaty window filtered to just that channel would have neared retail's 10,000-line depth — a user who opens that floaty window after a long session sees a much shorter backlog than retail would show for the same play session | `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640`/`TruncateChatLog @0x004F4711`; `gmFloatyMainChatUI::SetVisible @0x004CE9B0`; `docs/research/2026-08-09-chat-retail-window-shell.md` §1.2 | diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 954aef58..fcf3dd12 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -129,9 +129,9 @@ implementer per slice against a pinned contract (per dissolved with the `RetailWindowChrome.Imported` mount (0x2100006F's own border art IS the window chrome — no nine-slice wrapper, no content crop, frame==content) using the DAT's real - minH=100/maxH=2000/minW=300/maxW=2000. Register row AP-185 files the - one accepted simplification (the `_Locked` cosmetic border-art swap on - `UiLocked` is not ported; the live grip skin shows unconditionally). + minH=100/maxH=2000/minW=300/maxW=2000. The originally deferred AP-185 + `_Locked` cosmetic border-art swap was subsequently ported through the + shared registered-window lock presenter on 2026-08-20. - **CH6b — floating windows 1–4.** Mount `0x2100005B` ×4 as always-resident children per `gmGamePlayUI::SetupChildren @0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510); diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 727c340e..df53d351 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -509,10 +509,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server // bit at mount time — see CharacterOptionCombatSettingsSource's // doc comment. Live toggling afterward still flows through - // RuntimeSettingsController.SetUiLocked's existing - // _runtimeTargets?.ApplyUiLock push (ToggleUiLock's Bindings - // site, LiveSessionRuntimeFactory.cs, now also sends the wire - // bit — see its own comment). + // RuntimeSettingsController.RequestUiLocked's authoritative + // option-command then immediate ApplyUiLock push. Server reseeds + // use SetUiLocked directly so they never echo the bit to the wire. host.Root.UiLocked = d.Character.Options.GetOptionBit( CharacterOptionId.LockUI); @@ -695,7 +694,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory Radar: new RadarRuntimeBindings( late.Radar.Snapshot, d.Actions.Selection, - d.Settings.SetUiLocked), + d.Settings.RequestUiLocked), Combat: new CombatRuntimeBindings( d.Actions.Combat, combatAttack), diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 06491c78..bfdacb1e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -712,7 +712,8 @@ public sealed class GameWindow : _runtimeSettings = new RuntimeSettingsController( new JsonRuntimeSettingsStorage( _applicationPaths.SettingsFile), - log: Console.WriteLine); + log: Console.WriteLine, + characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; _animatedEntities = new LiveEntityAnimationRuntimeView( diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index 2c804526..d22dbb46 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -140,6 +140,7 @@ internal sealed class RuntimeSettingsController : private readonly IRuntimeSettingsStorage _storage; private readonly Func _resolveQuality; + private readonly Func? _characterOptionValue; private readonly Action _log; private IRuntimeSettingsTargets? _runtimeTargets; private CharacterSettings _defaultCharacter; @@ -156,11 +157,13 @@ internal sealed class RuntimeSettingsController : public RuntimeSettingsController( IRuntimeSettingsStorage storage, Func? resolveQuality = null, - Action? log = null) + Action? log = null, + Func? characterOptionValue = null) { _storage = storage ?? throw new ArgumentNullException(nameof(storage)); _resolveQuality = resolveQuality ?? ResolveQuality; _log = log ?? Console.WriteLine; + _characterOptionValue = characterOptionValue; Display = _storage.LoadDisplay(); Audio = _storage.LoadAudio(); @@ -293,6 +296,34 @@ internal sealed class RuntimeSettingsController : _lastAppliedUiLocked = locked; } + /// + /// Handles a user-originated request to change retail's LockUI character + /// option. The authoritative Runtime option write/send is published first; + /// the retained-root presentation follows immediately, matching retail's + /// PlayerModule write before global UI message 0x0D broadcast. + /// Server-seed convergence must continue to call + /// directly so receiving an authoritative bit never echoes it to the wire. + /// + public void RequestUiLocked(bool locked) + { + IRuntimeSettingsTargets? targets = _runtimeTargets; + if (targets is null || _characterOptionValue is null) + return; + + targets.SetSingleCharacterOption( + (uint)CharacterOptionId.LockUI, + locked); + + // LiveSessionCommandRouter applies RuntimeCharacterOptionsState's + // local write synchronously before the autosave send. If the route is + // inactive/displaced, Publish is intentionally dropped and the bit + // remains unchanged; do not split root presentation from authority. + if (_characterOptionValue((uint)CharacterOptionId.LockUI) != locked) + return; + + SetUiLocked(locked); + } + /// /// Retail ClientCommunicationSystem::DoFrameRate @ 0x005707D0 /// flips the live flag and sends the framerate-display UI notice. acdream diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs index 1c98994b..51d51f0a 100644 --- a/src/AcDream.App/UI/CursorFeedbackController.cs +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -132,9 +132,12 @@ public sealed class CursorFeedbackController // found flag driving the Default/Combat/Use/Examine/Busy Found // variants too. RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction); - uint hoverTarget = hover is null - ? _worldTargetProvider?.Invoke() ?? 0u - : FindHoveredItemSlot(hover)?.ItemId ?? 0u; + UiItemSlot? hoveredItem = FindHoveredItemSlot(hover); + uint hoverTarget = hoveredItem is not null + ? hoveredItem.ItemId + : FindRepresentedObject(hover) is { } represented + ? represented + : _worldTargetProvider?.Invoke() ?? 0u; bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget && hoverTarget != 0 ? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget) @@ -377,4 +380,19 @@ public sealed class CursorFeedbackController } return null; } + + private static uint? FindRepresentedObject(UiElement? element) + { + while (element is not null) + { + if (element.FoundObjectGuidProvider is { } provider) + { + uint guid = provider(); + if (guid != 0u) + return guid; + } + element = element.Parent; + } + return null; + } } diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 182f5f8b..02fc4eb1 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -73,14 +73,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private const uint Indicator3Id = 0x10000524u; private const uint Indicator4Id = 0x10000525u; - // The 8 cosmetic "_Locked" border-art twins - // (gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 swaps these in for the 8 - // live Resizebar/Dragbar grips above when PlayerModule::LockUI is true — see - // research doc §1.6). CH6a does not implement the lock-state art swap (register - // row AP-185 — UiRoot.UiLocked already gates INTERACTION generically, independent - // of which art is shown); default to the unlocked visual (hide these, show the - // live grips) to match UiRoot's own UiLocked=false default and avoid double - // rendering two overlapping border-art layers. + // The 8 cosmetic "_Locked" border-art twins. Retail's + // gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 swaps these with the 8 + // live Resizebar/Dragbar grips. Bind seeds the ordinary unlocked visual for + // standalone/unregistered layouts; RetailWindowLockPresentationController + // takes authoritative ownership as soon as the window registers. private static readonly uint[] LockedTwinIds = { 0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u, @@ -259,10 +256,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta _windowFilters = windowFilters, }; - // The 8 cosmetic "_Locked" border-art twins default HIDDEN — CH6a does not - // implement retail's UiLocked-driven art swap (see the class doc + the - // LockedTwinIds field comment); the 8 live grip/dragbar elements (which - // occupy the SAME rects one ReadOrder layer above) are the ones shown. + // Seed the unlocked skin until the common registered-window presenter + // applies the canonical UiLocked state. The live and locked sets occupy + // identical rects, so only one set may be visible at a time. foreach (uint id in LockedTwinIds) if (layout.FindElement(id) is { } twin) twin.Visible = false; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 320a6eec..7fb41022 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -571,8 +571,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _selection.Select(payload.ObjId, SelectionChangeSource.Inventory); } - /// Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid - /// drops accept; a side-bag/main-pack drop rejects only when that container is known full. + /// Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral. Retail keeps + /// loose-item slots and contained-container slots as different drag classes: a pack is rejected + /// by the contents grid and accepted by the player's pack-slot list, while an ordinary item is + /// accepted by the contents grid or by a non-full occupied pack. public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { // UIElement_ItemList::ItemList_DragOver @ 0x004E3400 only evaluates @@ -583,10 +585,22 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo return ItemDragAcceptance.None; if (payload.ObjId == 0) return ItemDragAcceptance.Reject; + bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source); if (targetList == _contentsGrid) - return ItemDragAcceptance.Accept; + return sourceIsBag + ? ItemDragAcceptance.Reject + : ItemDragAcceptance.Accept; if (targetList == _containerList || targetList == _topContainer) { + // UIElement_ItemList::ItemList_DragOver @0x004E3400 checks the + // dragged object's container flag before interpreting this list. + // A container drag addresses the player's contained-container + // list itself; an empty authored slot is therefore a valid pack + // destination rather than "no target". + if (sourceIsBag) + return targetCell.ItemId == payload.ObjId + ? ItemDragAcceptance.Reject + : ItemDragAcceptance.Accept; if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return ItemDragAcceptance.Reject; return IsContainerFull(targetCell.ItemId) @@ -610,6 +624,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo uint item = payload.ObjId; if (item == 0) return; + // DropReleased is still delivered to the list after a reject overlay; + // pin the release to the same retail policy instead of relying on the + // advisory color alone. + if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) + return; + // UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every // release while m_pendingItem exists, before merge, split, or ordinary // placement. ItemList_DragOver has no equivalent gate, so hover may @@ -635,6 +655,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo && TryMergeStacks(item, targetCell.ItemId)) return; + bool sourceIsBag = _objects.Get(item) is { } dragged && IsBag(dragged); uint container; int placement; if (targetList == _contentsGrid) { @@ -645,10 +666,23 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo } else if (targetList == _containerList || targetList == _topContainer) { - if (targetCell.ItemId == 0 || targetCell.ItemId == item) return; - container = targetCell.ItemId; // the bag / main pack - if (IsContainerFull(container)) return; // red already shown - placement = _objects.GetContents(container).Count; // append into it + if (sourceIsBag) + { + // A pack dropped on the pack selector is inserted into the + // player's contained-container list at that selector slot. + // Sending the open item container here makes ACE correctly + // reject the pickup with "can't be picked up". + container = _playerGuid(); + if (container == 0u) return; + placement = Math.Max(0, targetCell.SlotIndex); + } + else + { + if (targetCell.ItemId == 0 || targetCell.ItemId == item) return; + container = targetCell.ItemId; // the bag / main pack + if (IsContainerFull(container)) return; // red already shown + placement = _objects.GetContents(container).Count; // append into it + } } else return; diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index e7125ed9..1429cedc 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -612,11 +612,15 @@ public static class LayoutImporter bool imageRead = false; foreach (var m in sd.Media) { - if (!imageRead && m is MediaDescImage img && img.File != 0) + if (m is MediaDescImage img) { - info.StateMedia[name] = (img.File, (int)img.DrawMode); - state.Image = new UiImageMedia(img.File, (int)img.DrawMode); - imageRead = true; + state.ImageMediaCount++; + if (!imageRead && img.File != 0) + { + info.StateMedia[name] = (img.File, (int)img.DrawMode); + state.Image = new UiImageMedia(img.File, (int)img.DrawMode); + imageRead = true; + } } if (m is MediaDescCursor cursor && cursor.File != 0) diff --git a/src/AcDream.App/UI/Layout/RadarController.cs b/src/AcDream.App/UI/Layout/RadarController.cs index 1097691b..049a834e 100644 --- a/src/AcDream.App/UI/Layout/RadarController.cs +++ b/src/AcDream.App/UI/Layout/RadarController.cs @@ -70,8 +70,15 @@ public sealed class RadarController : IRetainedPanelController _lockButton = layout.FindElement(LockButtonId) as UiButton; _dragButton = layout.FindElement(DragButtonId); - if (_lockButton is not null && _setUiLocked is not null) - _lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false)); + if (_lockButton is not null) + { + // LockedUI/UnlockedUI are persistent semantic faces. The button's + // authored, media-less Normal_pressed state must not replace them + // during pointer traffic after the global lock transition. + _lockButton.ControllerOwnsVisualState = true; + if (_setUiLocked is not null) + _lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false)); + } _tokens = [ diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index e0b0c010..fd844fad 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -178,6 +178,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont _inventoryButton = layout.FindElement(InventoryButtonId) as UiButton; if (_inventoryButton is not null) { + // Retail's inventory button is also the player/self backpack + // target: target-mode clicks call OfferSelfPrimaryClick and drops + // resolve to the player's inventory. Expose that same represented + // object to the global cursor path so ordinary hover uses the + // DefaultFound cursor instead of the unlit Default cursor. + _inventoryButton.FoundObjectGuidProvider = () => + _playerGuid?.Invoke() ?? _itemInteraction?.PlayerGuid ?? 0u; // gmToolbarUI::HandleInventoryButtonDragOver @ 0x004BD180 uses the // authored child overlay's Accept state (0x10000046 = 0x060011F7). // UiButton owns the retained drop-target seam; the panel owns policy. diff --git a/src/AcDream.App/UI/Layout/UiPropertyBag.cs b/src/AcDream.App/UI/Layout/UiPropertyBag.cs index 35d95956..377a16d3 100644 --- a/src/AcDream.App/UI/Layout/UiPropertyBag.cs +++ b/src/AcDream.App/UI/Layout/UiPropertyBag.cs @@ -138,6 +138,15 @@ public sealed class UiStateInfo /// public int MediaCount; + /// + /// Number of image media entries in the effective retail state, including + /// an image whose file id is invalid/zero and therefore means + /// draw-nothing. This is deliberately separate from : + /// a cursor-, sound-, or message-only state resets retail's media machine + /// but does not replace the image already installed on the element. + /// + public int ImageMediaCount; + public UiStateInfo Clone() => new() { @@ -149,6 +158,7 @@ public sealed class UiStateInfo Cursor = Cursor, Properties = Properties.Clone(), MediaCount = MediaCount, + ImageMediaCount = ImageMediaCount, }; public static UiStateInfo Merge(UiStateInfo baseState, UiStateInfo derivedState) @@ -161,11 +171,9 @@ public sealed class UiStateInfo Image = derivedState.Image ?? baseState.Image, Cursor = derivedState.Cursor ?? baseState.Cursor, Properties = UiPropertyBag.Merge(baseState.Properties, derivedState.Properties), - // Media arrays do not merge entry-wise in retail (a derived - // StateDesc replaces the base one); the derived count wins when - // the derived state authors ANY media, else the base's carries. - MediaCount = derivedState.MediaCount != 0 - ? derivedState.MediaCount - : baseState.MediaCount, + // StateDesc::Incorporate @0x0069CCA0 calls ConcatenateMedia; + // inherited media arrays append rather than replace one another. + MediaCount = baseState.MediaCount + derivedState.MediaCount, + ImageMediaCount = baseState.ImageMediaCount + derivedState.ImageMediaCount, }; } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 22a915a8..b27616f1 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -529,6 +529,8 @@ public sealed class RetailUiRuntime : IDisposable // ChatInterface base-constructor values, 0x004F4550 — when no store is // wired or nothing has been saved yet). ChatSettings chatSettings = bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default; + WindowLockPresentation = new RetailWindowLockPresentationController( + bindings.Host.Root.WindowManager); WindowOpacity = new RetailWindowOpacityController( bindings.Host.Root.WindowManager, chatSettings.DefaultOpacity, @@ -643,6 +645,13 @@ public sealed class RetailUiRuntime : IDisposable /// public RetailWindowOpacityController WindowOpacity { get; } + /// + /// Global retail UI-lock chrome presenter. It is attached before the first + /// window mount so late registration observes the current lock state before + /// the window's first rendered frame. + /// + public RetailWindowLockPresentationController WindowLockPresentation { get; } + /// /// Shared dat/sprite/font resolvers this runtime was built with. /// Campaign CH user-gate round 3: lets a controller built OUTSIDE this @@ -903,7 +912,7 @@ public sealed class RetailUiRuntime : IDisposable else if (_screenSizeSettling) { _screenSizeSettling = false; - _persistence?.RestoreAll(saveBack: false); + _persistence?.RestoreAfterDisplayChange(); } Host.Draw(screenSize); @@ -4542,6 +4551,7 @@ public sealed class RetailUiRuntime : IDisposable { _characterSheetSubscription?.Dispose(); Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; + WindowLockPresentation.Dispose(); WindowOpacity.Dispose(); if (SecureTradeController is { } trade) { diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs index efbeb673..de2ae061 100644 --- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs +++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs @@ -48,6 +48,27 @@ public sealed class RetailWindowLayoutPersistence : IDisposable /// mid-drag reload would litter settings.json with intermediate-resolution /// keys. public void RestoreAll(bool saveBack = true) + => RestoreAllCore( + saveBack, + restoreVisibility: true); + + /// + /// Reloads the current resolution's saved geometry after a live display + /// change. Retail's UI-<char>-<world>-<W>-<H>.txt + /// records only X/Y/W/H; global message 0xE therefore cannot hide + /// a window. Keeping live visibility is especially load-bearing for the Options window: + /// hiding Config while its Resolution row is still uncommitted invokes + /// PlayerOptionPage::OnVisibilityChanged(false) and restores the + /// previous resolution, producing a resize-out/resize-back blip. + /// + public void RestoreAfterDisplayChange() + => RestoreAllCore( + saveBack: false, + restoreVisibility: false); + + private void RestoreAllCore( + bool saveBack, + bool restoreVisibility) { ObjectDisposedException.ThrowIf(_disposed, this); string character = _characterKey(); @@ -72,7 +93,9 @@ public sealed class RetailWindowLayoutPersistence : IDisposable handle, layout, screen, - restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name)); + restoreVisibility: + restoreVisibility + && !_stateManagedVisibilityWindows.Contains(handle.Name)); // Lazily migrate legacy position-only entries into the complete schema. if (saveBack) _store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle)); diff --git a/src/AcDream.App/UI/RetailWindowLockPresentationController.cs b/src/AcDream.App/UI/RetailWindowLockPresentationController.cs new file mode 100644 index 00000000..894bc65e --- /dev/null +++ b/src/AcDream.App/UI/RetailWindowLockPresentationController.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.UI; + +/// +/// Applies retail's global UI-lock presentation to every registered retained +/// window. Interaction remains owned by ; this +/// controller owns only the corresponding chrome swap. +/// +public sealed class RetailWindowLockPresentationController : IDisposable +{ + private static readonly (uint LockedStart, uint LiveStart)[] AuthoredChromeBlocks = + [ + (0x10000633u, 0x1000063Bu), + (0x10000643u, 0x1000064Bu), + (0x10000653u, 0x1000065Bu), + (0x10000663u, 0x1000066Bu), + (0x10000673u, 0x1000067Bu), + (0x10000683u, 0x1000068Bu), + (0x10000693u, 0x1000069Bu), + (0x100006A5u, 0x100006ADu), + ]; + + private const uint SmartBoxLiveChromeStart = 0x100006CAu; + + private readonly RetailWindowManager _manager; + private readonly Dictionary _windows = new(); + private bool _disposed; + + public RetailWindowLockPresentationController(RetailWindowManager manager) + { + _manager = manager ?? throw new ArgumentNullException(nameof(manager)); + _manager.WindowRegistering += OnWindowRegistered; + _manager.WindowUnregistered += OnWindowUnregistered; + + foreach (RetailWindowHandle handle in _manager.Windows) + Attach(handle); + } + + private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle); + + private void OnWindowUnregistered(RetailWindowHandle handle) => Detach(handle); + + private void Attach(RetailWindowHandle handle) + { + if (_windows.ContainsKey(handle)) + return; + + var presentation = WindowPresentation.Capture(handle.OuterFrame); + _windows.Add(handle, presentation); + handle.LockChanged += OnLockChanged; + presentation.Apply(_manager.IsLocked); + } + + private void Detach(RetailWindowHandle handle) + { + handle.LockChanged -= OnLockChanged; + _windows.Remove(handle); + } + + private void OnLockChanged(RetailWindowHandle handle, bool locked) + { + if (_windows.TryGetValue(handle, out WindowPresentation? presentation)) + presentation.Apply(locked); + } + + private static bool IsAuthoredLockedChrome(uint id) + { + foreach ((uint start, _) in AuthoredChromeBlocks) + if (id >= start && id < start + 8u) + return true; + return false; + } + + private static bool IsAuthoredLiveChrome(uint id) + { + foreach ((_, uint start) in AuthoredChromeBlocks) + if (id >= start && id < start + 8u) + return true; + return id >= SmartBoxLiveChromeStart && id < SmartBoxLiveChromeStart + 8u; + } + + private sealed class WindowPresentation + { + private readonly List _authoredLockedChrome = new(); + private readonly List<(UiElement Element, bool VisibleWhenUnlocked)> _liveChrome = new(); + private readonly List<(UiNineSlicePanel Panel, bool VisibleWhenUnlocked)> _nineSlices = new(); + + public static WindowPresentation Capture(UiElement outerFrame) + { + var presentation = new WindowPresentation(); + presentation.CaptureElement(outerFrame); + return presentation; + } + + private void CaptureElement(UiElement element) + { + if (element is UiNineSlicePanel nineSlice) + _nineSlices.Add((nineSlice, nineSlice.DrawResizeAffordances)); + + if (IsAuthoredLockedChrome(element.DatElementId)) + { + _authoredLockedChrome.Add(element); + } + else if (IsAuthoredLiveChrome(element.DatElementId) || element is UiResizeGrip) + { + _liveChrome.Add((element, element.Visible)); + } + + foreach (UiElement child in element.Children) + CaptureElement(child); + } + + public void Apply(bool locked) + { + foreach (UiElement element in _authoredLockedChrome) + element.Visible = locked; + + foreach ((UiElement element, bool visibleWhenUnlocked) in _liveChrome) + element.Visible = !locked && visibleWhenUnlocked; + + foreach ((UiNineSlicePanel panel, bool visibleWhenUnlocked) in _nineSlices) + panel.DrawResizeAffordances = !locked && visibleWhenUnlocked; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _manager.WindowRegistering -= OnWindowRegistered; + _manager.WindowUnregistered -= OnWindowUnregistered; + foreach (RetailWindowHandle handle in new List(_windows.Keys)) + Detach(handle); + } +} diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index 51cab64c..3a364047 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -43,6 +43,12 @@ public sealed class RetailWindowManager : IDisposable /// public event Action? WindowRegistered; + /// + /// Internal pre-exposure hook for presentation state that must be correct + /// before a newly mounted window's controller receives OnShown. + /// + internal event Action? WindowRegistering; + /// /// Fires when a window is REMOVED from the registry (), /// after teardown (NotifyClosed/DisposeController) but before the @@ -99,6 +105,7 @@ public sealed class RetailWindowManager : IDisposable authoredGeometryRevision); _byName.Add(name, handle); _byFrame.Add(outerFrame, handle); + WindowRegistering?.Invoke(handle); handle.NotifyInitialState(); WindowRegistered?.Invoke(handle); return handle; @@ -258,6 +265,7 @@ public sealed class RetailWindowManager : IDisposable _defaultInputs.Remove(handle); handle.NotifyClosed(); handle.DisposeController(); + WindowUnregistered?.Invoke(handle); } } diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index c5fd0d21..1e5297ce 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -308,6 +308,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// public bool SuppressSelfToggle { get; set; } + /// + /// Gives the owning controller exclusive responsibility for the rendered + /// state. Automatic pointer, selection, and enabled-state transitions no + /// longer replace ; input and click delivery remain + /// unchanged. This is used by buttons such as the radar UI lock whose face + /// represents durable application state rather than momentary interaction. + /// + public bool ControllerOwnsVisualState { get; set; } + public bool Selected { get => _selected; @@ -509,14 +518,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// /// Retail's SetState media rule (UIElement::SetState @0x00464E70 - /// tail, the m_media.m_num != 0 gate @0x004651c0): a committed - /// state replaces the playing media ONLY when its media array is - /// non-empty — an authored empty-media state keeps the PREVIOUS media - /// (why an empty Normal_pressed never blanks a Normal-art - /// button), while an authored File=0 draw-nothing image counts as media - /// and clears the face (#416: the roster-row bar children's base - /// state). An UNAUTHORED committed state runs retail's state-0 arm - /// against the base media array. Face segments model retail's + /// tail plus MediaMachine::Update_Image @0x00465870): a committed + /// state replaces the playing IMAGE only when its effective media array + /// contains image media. An empty state—or a cursor/sound/message-only + /// state—keeps the PREVIOUS image (why the toolbar inventory button's + /// inherited non-image Normal_pressed media never blanks its closed + /// backpack art), while an authored File=0 draw-nothing image clears the + /// face (#416: the roster-row bar children's base state). An UNAUTHORED + /// committed state runs retail's state-0 arm against the base media array. + /// Face segments model retail's /// PassToChildren children, so each segment resolves the rule against /// its OWN authored states. Synced lazily on the first draw after any /// write so every commit path (the visual @@ -553,7 +563,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful string current) { if (info.States.TryGetValue(committedId, out UiStateInfo? state)) - return state.MediaCount != 0 ? committedName : current; + return HasImageMedia(info, state, committedName) ? committedName : current; // Synthetic/test infos may carry StateMedia without States entries; // a drawable entry for the committed name counts as authored media. if (info.StateMedia.ContainsKey(committedName)) @@ -562,10 +572,18 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful // otherwise the previous media keeps playing. if (info.States.TryGetValue( UiStateInfo.DirectStateId, out UiStateInfo? baseState)) - return baseState.MediaCount != 0 ? "" : current; + return HasImageMedia(info, baseState, "") ? "" : current; return info.StateMedia.ContainsKey("") ? "" : current; } + private static bool HasImageMedia( + ElementInfo info, + UiStateInfo state, + string stateName) + // StateMedia keeps committed pre-ImageMediaCount fixtures and compact + // synthetic tests faithful for ordinary non-zero image entries. + => state.ImageMediaCount != 0 || info.StateMedia.ContainsKey(stateName); + /// /// Returns the File id the media rule selected for this face; 0 draws /// nothing (an authored File=0 image reaches this as a media-state whose @@ -1037,6 +1055,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful private void UpdateVisualState() { + if (ControllerOwnsVisualState) + return; + uint requested = ComputeRequestedStateId(); if (_hasCustomSelectionPair) { diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index cbea01b6..9ef4c286 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -121,6 +121,16 @@ public abstract class UiElement /// public uint SourceLayoutDid { get; internal set; } + /// + /// Optional game object represented by this retained widget for retail's + /// global found-object cursor. Most widgets leave this unset. The toolbar + /// inventory/backpack button supplies the player object: retail treats that + /// button as the self/backpack target (the same target used by its click and + /// drop paths), so hovering it selects the Found cursor family even though + /// the authored element is a Button rather than a UIItem. + /// + public Func? FoundObjectGuidProvider { get; set; } + /// /// #409: mirrors ElementInfo.TooltipTextChildElementId (dat /// property 0x4A). Meaningful only when read off a tooltip diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index a87ce024..6425b929 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -29,6 +29,13 @@ public class UiNineSlicePanel : UiPanel /// public bool DrawCenterFill { get; set; } = true; + /// + /// Whether to paint the live gold resize edges and corner studs over the + /// ordinary retail bevel. UI lock disables only this affordance layer; + /// the frame and optional center fill remain visible. + /// + public bool DrawResizeAffordances { get; set; } = true; + public UiNineSlicePanel(System.Func resolve) { _resolve = resolve; @@ -90,6 +97,9 @@ public class UiNineSlicePanel : UiPanel DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL); DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR); + if (!DrawResizeAffordances) + return; + // Resize-grip overlay (gold ridged edges + square corner studs) on top of the // bevel — the second border layer the vitals LayoutDesc carries (0x1000063B–0x10000642). DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top); diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index 6941bbaa..b122baf0 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using AcDream.Core.Items; using AcDream.Core.Physics; +using AcDream.Core.Properties; using AcDream.Core.Spells; namespace AcDream.Core.Player; @@ -305,7 +306,7 @@ public sealed class LocalPlayerState /// public uint? GetMaxApprox(VitalKind kind) { - uint? baseValue = GetBaseMaxApprox(kind); + uint? baseValue = GetMaxBeforeSecondaryEnchantments(kind); if (baseValue is not uint unbuffed) return null; // Preserve the "no data" sentinel — when the unbuffed max is 0 // we lack the inputs to compute anything reasonable. The retail @@ -315,12 +316,13 @@ public sealed class LocalPlayerState var mod = _spellbook?.GetVitalMod(StatKeyForKind(kind)) ?? EnchantmentMath.VitalMod.Identity; // Apply: (unbuffed * mult) + additive, then clamp to retail's - // min-vital floor (5 if base >= 5 else 1) — matches - // CreatureVital::GetMaxValue at PDB 0x0058F2DD. + // min-vital floor (5 if base >= 5 else 1). The final cast is + // retail CEnchantmentRegistry::EnchantAttribute2nd's _ftol2 + // conversion at 0x00594787: truncate toward zero, do not round. float buffed = (unbuffed * mod.Multiplier) + mod.Additive; uint minFloor = unbuffed >= 5 ? 5u : 1u; if (buffed < minFloor) buffed = minFloor; - return (uint)System.Math.Round(buffed); + return (uint)buffed; } /// @@ -333,7 +335,27 @@ public sealed class LocalPlayerState if (vital is null) return null; return vital.Value.Ranks + vital.Value.Start - + AttributeContribution(kind); + + AttributeContribution(kind, effective: false) + + GearHealthBonus(kind); + } + + /// + /// Retail CACQualities::InqAttribute2nd @ 0x00592020 computes a + /// maximum vital's formula contribution through InqAttribute with + /// enchantments enabled, adds GearMaxHealth (property 379) for + /// health, and only then calls EnchantAttribute2nd. Keeping this + /// stage separate prevents primary-attribute records from leaking into + /// the secondary-attribute modifier while still allowing buffed + /// Endurance/Self to feed the vital formula. + /// + private uint? GetMaxBeforeSecondaryEnchantments(VitalKind kind) + { + VitalSnapshot? vital = Get(kind); + if (vital is null) return null; + return vital.Value.Ranks + + vital.Value.Start + + AttributeContribution(kind, effective: true) + + GearHealthBonus(kind); } /// @@ -622,21 +644,37 @@ public sealed class LocalPlayerState // coefficients, but the values themselves don't change between dat // versions in retail. - private uint AttributeContribution(VitalKind kind) + private uint AttributeContribution(VitalKind kind, bool effective) { + uint endurance = GetAttrCurrent(AttributeKind.Endurance, effective); + uint self = GetAttrCurrent(AttributeKind.Self, effective); switch (kind) { case VitalKind.Health: - return GetAttrCurrent(AttributeKind.Endurance) / 2u; + // SecondaryAttributeTable's SkillFormula is + // floor((Endurance / 2) + 0.5), not integer truncation. + return (endurance / 2u) + (endurance & 1u); case VitalKind.Stamina: - return GetAttrCurrent(AttributeKind.Endurance); + return endurance; case VitalKind.Mana: - return GetAttrCurrent(AttributeKind.Self); + return self; default: return 0u; } } - private uint GetAttrCurrent(AttributeKind kind) => - _attrs.TryGetValue(kind, out var a) ? a.Current : 0u; + private uint GetAttrCurrent(AttributeKind kind, bool effective) + { + if (!_attrs.TryGetValue(kind, out var attribute)) return 0u; + if (!effective || _spellbook is null) return attribute.Current; + int value = GetEffectiveAttribute(kind) ?? 0; + return value > 0 ? (uint)value : 0u; + } + + private uint GearHealthBonus(VitalKind kind) + { + if (kind != VitalKind.Health) return 0u; + int value = _properties.GetInt((uint)PropertyInt.GearMaxHealth); + return value > 0 ? (uint)value : 0u; + } } diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index 7251d9cb..55eb6cb2 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -98,8 +98,12 @@ public sealed class Spellbook return cached; } - EnchantmentMath.VitalMod calculated = - EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey); + EnchantmentMath.VitalMod calculated = EnchantmentMath.GetMod( + ActiveEnchantments, + _table, + statKey, + EnchantmentMath.EnchantmentTypeFlag.SecondAtt, + includeVitae: true); _vitalModCache.Add(statKey, calculated); return calculated; } diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index 1cf040cb..5830751a 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -4,6 +4,7 @@ using AcDream.App.Combat; using AcDream.App.Composition; using AcDream.App.Diagnostics; using AcDream.App.Rendering; +using AcDream.App.Settings; using AcDream.App.Spells; using AcDream.Content; using AcDream.App.UI; @@ -35,6 +36,25 @@ public sealed class InteractionRetainedUiCompositionTests InteractionRetainedUiCompositionPoint.InventoryContainerBound, ]; + [Fact] + public void RadarLockBindingUsesAuthoritativeRequestInsteadOfPresentationOnlySetter() + { + MethodInfo compose = typeof(RetailInteractionRetainedUiCompositionFactory) + .GetMethod(nameof( + RetailInteractionRetainedUiCompositionFactory.CreateRetainedUi))!; + IReadOnlyList references = + CompiledCallGraph.ReadMethodReferences(compose); + + Assert.Contains( + references, + call => call.Target.DeclaringType == typeof(RuntimeSettingsController) + && call.Target.Name == nameof(RuntimeSettingsController.RequestUiLocked)); + Assert.DoesNotContain( + references, + call => call.Target.DeclaringType == typeof(RuntimeSettingsController) + && call.Target.Name == nameof(RuntimeSettingsController.SetUiLocked)); + } + [Fact] public void EnabledUiPublishesOneExactResultAfterFrozenConstructionOrder() { diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 7ffde711..ad8b7818 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -628,6 +628,43 @@ public sealed class LiveSessionCommandRouterTests isOlthoiPlayer: false).Status); } + [Fact] + public void SettingsRouteLockUi_UpdatesCanonicalBitSynchronouslyAndSendsAutosave() + { + var characterState = new RuntimeCharacterState(); + characterState.Options.SetOptionBit( + (uint)CharacterOptionId.LockUI, + false); + var sent = new List<(uint OptionId, bool Value)>(); + LiveSessionCommandRouter router = NewRouter( + characterState: characterState, + sendSingleCharacterOption: (id, value) => + characterState.Options.TrySetOption( + id, + value, + sendAutoSave: (sentId, sentValue) => + sent.Add((sentId, sentValue)))); + router.Activate(); + + router.Publish(new SetSingleCharacterOptionRuntimeCmd( + (uint)CharacterOptionId.LockUI, + true)); + + Assert.True(characterState.Options.GetOptionBit(CharacterOptionId.LockUI)); + Assert.Equal([((uint)CharacterOptionId.LockUI, true)], sent); + + router.Publish(new SetSingleCharacterOptionRuntimeCmd( + (uint)CharacterOptionId.LockUI, + false)); + + Assert.False(characterState.Options.GetOptionBit(CharacterOptionId.LockUI)); + Assert.Equal( + [ + ((uint)CharacterOptionId.LockUI, true), + ((uint)CharacterOptionId.LockUI, false), + ], sent); + } + [Fact] public void ShowWeenieErrorFriendsFull_ResolvesThroughAddText_AndLandsInSpewBoxNotChat() { diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index 7ae6e36d..cfdf8f7c 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -941,6 +941,85 @@ public sealed class RuntimeSettingsControllerTests Assert.Equal(["target-quality"], events); } + [Fact] + public void RequestUiLocked_PublishesAuthoritativeOptionBeforePresentation() + { + var events = new List(); + bool authoritativeLock = false; + var controller = new RuntimeSettingsController( + new FakeStorage(), + log: events.Add, + characterOptionValue: optionId => + optionId == (uint)CharacterOptionId.LockUI && authoritativeLock); + var targets = new FakeRuntimeTargets(events); + targets.SingleOptionApplied = (optionId, value) => + { + if (optionId == (uint)CharacterOptionId.LockUI) + authoritativeLock = value; + }; + controller.BindRuntimeTargets(targets); + + controller.RequestUiLocked(true); + + Assert.Equal( + [ + $"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:True", + "target-ui-lock:True", + ], events); + Assert.Equal( + [((uint)CharacterOptionId.LockUI, true)], + targets.SingleOptionCalls); + Assert.Equal(1, targets.UiLockCalls); + + events.Clear(); + controller.RequestUiLocked(false); + + Assert.Equal( + [ + $"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:False", + "target-ui-lock:False", + ], events); + Assert.Equal( + [ + ((uint)CharacterOptionId.LockUI, true), + ((uint)CharacterOptionId.LockUI, false), + ], targets.SingleOptionCalls); + Assert.Equal(2, targets.UiLockCalls); + } + + [Fact] + public void RequestUiLocked_InactiveRouteDoesNotSplitPresentation_AndServerSeedDoesNotEcho() + { + var events = new List(); + bool authoritativeLock = false; + var controller = new RuntimeSettingsController( + new FakeStorage(), + log: events.Add, + characterOptionValue: optionId => + optionId == (uint)CharacterOptionId.LockUI && authoritativeLock); + var targets = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(targets); + + // The fake records the publish but deliberately does not mutate the + // canonical option, modeling an inactive/displaced live router. + controller.RequestUiLocked(true); + + Assert.Equal( + [$"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:True"], + events); + Assert.Equal(0, targets.UiLockCalls); + + // A later PlayerDescription has already replaced Runtime authority; + // its convergence call is presentation-only and must not echo 0x0005. + authoritativeLock = true; + events.Clear(); + controller.SetUiLocked(true); + + Assert.Equal(["target-ui-lock:True"], events); + Assert.Single(targets.SingleOptionCalls); + Assert.Equal(1, targets.UiLockCalls); + } + [Fact] public void SetUiLocked_AppliesOnFirstCallThenNoOpsOnRepeatedSameValue() { @@ -1121,10 +1200,13 @@ public sealed class RuntimeSettingsControllerTests public List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = []; + public Action? SingleOptionApplied { get; set; } + public void SetSingleCharacterOption(uint optionId, bool value) { SingleOptionCalls.Add((optionId, value)); events.Add($"target-single-option:0x{optionId:X}:{value}"); + SingleOptionApplied?.Invoke(optionId, value); } public List<(float DefaultOpacity, float ActiveOpacity)> ChatOpacityCalls { get; } = []; diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index 8239b1ad..09ef77d6 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -1,4 +1,6 @@ using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.App.Tests.UI.Layout; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -354,7 +356,7 @@ public sealed class CursorFeedbackControllerTests } [Fact] - public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered() + public void UpdateFromRoot_worldProviderContinuesBehindNonItemUi() { var objects = SeedTargetObjects(); var interaction = new ItemInteractionController( @@ -376,11 +378,38 @@ public sealed class CursorFeedbackControllerTests // World hover (retail SmartBox found object) drives valid/invalid… Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind); - // …but UI occludes the world: hovering a plain panel → PENDING even - // though the world provider would return a valid target. + // UIElement_SmartBoxWrapper::FindObject @0x004E5430 only returns + // early for UIElement_UIItem. Ordinary UI chrome falls through to + // SmartBox::find_object, so it does not occlude the world pick. var panel = new UiPanel { Left = 390, Top = 290, Width = 40, Height = 40 }; root.AddChild(panel); - Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind); + Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind); + } + + [Fact] + public void UpdateFromRoot_toolbarBackpackRepresentsSelfAndShowsFoundCursorWithoutWorldHit() + { + ImportedLayout toolbar = FixtureLoader.LoadToolbar(); + using ToolbarController controller = ToolbarController.Bind( + toolbar, + new ClientObjectTable(), + new ShortcutStore(), + iconIds: static (_, _, _, _, _) => 0u, + useItem: static _ => { }, + playerGuid: () => Player); + var root = new UiRoot { Width = 800, Height = 600 }; + root.AddChild(toolbar.Root); + var backpack = Assert.IsType(toolbar.FindElement(0x100001B1u)); + System.Numerics.Vector2 position = backpack.ScreenPosition; + root.OnMouseMove( + (int)(position.X + backpack.Width * 0.5f), + (int)(position.Y + backpack.Height * 0.5f)); + var c = new CursorFeedbackController(worldTargetProvider: () => 0u); + + CursorFeedback feedback = c.Update(root); + + Assert.Equal(CursorFeedbackKind.Default, feedback.Kind); + Assert.Equal(RetailGlobalCursorKind.DefaultFound, feedback.GlobalKind); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index ecaac5b0..28558957 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -200,11 +200,11 @@ public class ChatLayoutConformanceTests [InlineData(0x10000698u)] [InlineData(0x10000699u)] [InlineData(0x1000069Au)] - public void MountedChatWindow_LockedTwinBorderArt_DefaultsHidden(uint lockedTwinId) + public void BoundChatWindow_LockedTwinBorderArt_SeedsUnlockedUntilRegistration(uint lockedTwinId) { - // Register row AP-185: CH6a shows only the live (unlocked) grip/dragbar - // border-art set by default, matching UiRoot.UiLocked's own false - // default and avoiding a double-rendered border. + // Bind seeds the unlocked skin for standalone layouts. Once registered, + // RetailWindowLockPresentationController applies the canonical UiLocked + // state; its real-fixture swap is covered by that controller's tests. var infos = FixtureLoader.LoadChatInfos(); var layout = LayoutImporter.Build(infos, NoTex, null); var controller = ChatWindowController.Bind( diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index fd61d255..418b54df 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -1483,6 +1483,80 @@ public class InventoryControllerTests ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green } + [Fact] + public void GroundPack_rejectsContentsGrid_butEmptyPackSlotAcceptsAndPicksUpAtThatSlot() + { + const uint droppedPack = 0x700000C0u; + var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Type = ItemType.Creature, + ItemsCapacity = 102, + ContainersCapacity = 7, + }); + SeedBag(objects, 0x500000C1u, slot: 0); + SeedBag(objects, 0x500000C2u, slot: 1); + objects.AddOrUpdate(new ClientObject + { + ObjectId = droppedPack, + Name = "Dropped Pack", + Type = ItemType.Container, + ItemsCapacity = 24, + }); + var puts = new List<(uint Item, uint Container, int Placement)>(); + using var interaction = new ItemInteractionController( + objects, + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState( + new InventoryTransactionState(objects)), + new InteractionState(), + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => droppedPack, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var controller = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + var source = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + source.SetItem(droppedPack, 0u); + var payload = new ItemDragPayload( + droppedPack, + ItemDragSource.Ground, + SourceSlot: 0, + SourceCell: source); + + Assert.Equal( + ItemDragAcceptance.Reject, + controller.OnDragOver(grid, grid.GetItem(0)!, payload)); + controller.HandleDropRelease(grid, grid.GetItem(0)!, payload); + Assert.Empty(puts); + + UiItemSlot emptyPackSlot = containers.GetItem(2)!; + Assert.Equal(0u, emptyPackSlot.ItemId); + Assert.Equal( + ItemDragAcceptance.Accept, + controller.OnDragOver(containers, emptyPackSlot, payload)); + controller.HandleDropRelease(containers, emptyPackSlot, payload); + + Assert.Equal(new[] { (droppedPack, Player, 2) }, puts); + Assert.True(interaction.TryGetPendingBackpackPlacement(droppedPack, out var pending)); + Assert.Equal(Player, pending.ContainerId); + Assert.Equal(2, pending.Placement); + } + [Fact] public void OnDragLift_selectsItem_butKeepsItUntilServerConfirms() { diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs index 2d18244d..192d3872 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs @@ -1,6 +1,8 @@ using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.App.Rendering; +using AcDream.App.Tests.Rendering.Gpu; using AcDream.Core.Ui; namespace AcDream.App.Tests.UI.Layout; @@ -101,6 +103,111 @@ public sealed class RadarControllerTests Assert.False(layout.FindElement(RadarController.CoordinateContainerId)!.Visible); } + [Fact] + public void Bind_RealFixture_RootPointerLockCycleRoundTripsAuthorityAndMedia() + { + var layout = LayoutImporter.Build( + FixtureLoader.LoadRadarInfos(), + static file => (file, 8, 8), + null); + var root = new UiRoot { Width = 800, Height = 600 }; + root.AddChild(layout.Root); + var state = UiRadarSnapshot.Empty with { UiLocked = false }; + var requestedLocks = new List(); + using var controller = RadarController.Bind( + layout, + () => state, + setUiLocked: value => + { + // Production's corrected contract: update the authoritative + // character option before pushing the retained-root message. + requestedLocks.Add(value); + state = state with { UiLocked = value }; + root.UiLocked = value; + }); + + var radar = Assert.IsType(layout.Root); + var lockButton = Assert.IsType( + layout.FindElement(RadarController.LockButtonId)); + var dragButton = Assert.IsType( + layout.FindElement(RadarController.DragButtonId)); + + radar.Refresh(); + Assert.Equal("UnlockedUI", lockButton.ActiveState); + Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton)); + Assert.True(lockButton.Visible); + Assert.False(lockButton.ClickThrough); + Assert.True(dragButton.Visible); + Assert.True(radar.Draggable); + + ClickThroughRoot(root, lockButton); + radar.Refresh(); + + Assert.Equal([true], requestedLocks); + Assert.True(root.UiLocked); + Assert.Equal("LockedUI", lockButton.ActiveState); + Assert.Equal(0x060074B7u, DrawnFaceFile(lockButton)); + Assert.True(lockButton.Visible); + Assert.False(lockButton.ClickThrough); + Assert.False(dragButton.Visible); + Assert.False(radar.Draggable); + + MoveAwayAndBack(root, lockButton); + Assert.Equal("LockedUI", lockButton.ActiveState); + Assert.Equal(0x060074B7u, DrawnFaceFile(lockButton)); + + // UiLocked suppresses move/resize only. The same radar button remains + // hit-testable and must be able to publish the authoritative unlock. + ClickThroughRoot(root, lockButton); + radar.Refresh(); + + Assert.Equal([true, false], requestedLocks); + Assert.False(root.UiLocked); + Assert.Equal("UnlockedUI", lockButton.ActiveState); + Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton)); + Assert.True(lockButton.Visible); + Assert.False(lockButton.ClickThrough); + Assert.True(dragButton.Visible); + Assert.True(radar.Draggable); + + MoveAwayAndBack(root, lockButton); + Assert.Equal("UnlockedUI", lockButton.ActiveState); + Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton)); + } + + private static void ClickThroughRoot(UiRoot root, UiButton button) + { + Vector2 position = button.ScreenPosition; + int x = (int)(position.X + button.Width * 0.5f); + int y = (int)(position.Y + button.Height * 0.5f); + root.OnMouseMove(x, y); + root.OnMouseDown(UiMouseButton.Left, x, y); + root.OnMouseUp(UiMouseButton.Left, x, y); + } + + private static void MoveAwayAndBack(UiRoot root, UiButton button) + { + root.OnMouseMove(700, 500); + Vector2 position = button.ScreenPosition; + root.OnMouseMove( + (int)(position.X + button.Width * 0.5f), + (int)(position.Y + button.Height * 0.5f)); + } + + private static uint DrawnFaceFile(UiButton button) + { + var renderer = new TextRenderer( + new RecordingGpuDevice(), new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(200f, 200f)); + button.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(200f, 200f))); + return Assert.Single(renderer.DebugSpriteSegments).Texture; + } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null; + } + private static ImportedLayout BuildRadarLayout() { var root = new ElementInfo diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 31fb6e31..c04f76bd 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -465,6 +465,73 @@ public class ToolbarControllerTests Assert.Equal("Normal", characterButton.ActiveState); } + [Fact] + public void RetailFixture_inventoryButtonPressKeepsClosedFaceThenOpensDirectly() + { + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadToolbarInfos(), + static file => (file, 8, 8), + null); + var controller = ToolbarController.Bind( + layout, + new ClientObjectTable(), + new ShortcutStore(), + iconIds: (_, _, _, _, _) => 0u, + useItem: _ => { }); + controller.BindPanelButtons( + _ => true, + panelId => controller.SetPanelOpen(panelId, open: true)); + var inventory = Assert.IsType(layout.FindElement(InventoryButtonId)); + + Assert.Equal("Normal", inventory.ActiveState); + Assert.Equal(0x06004CF7u, DrawnFaceFile(inventory)); + + inventory.OnEvent(new UiEvent( + inventory.EventId, + inventory, + UiEventType.MouseDown, + Data1: 31, + Data2: 29)); + + Assert.Equal("Normal_pressed", inventory.ActiveState); + Assert.Equal(0x06004CF7u, DrawnFaceFile(inventory)); + + inventory.OnEvent(new UiEvent( + inventory.EventId, + inventory, + UiEventType.MouseUp, + Data1: 31, + Data2: 29)); + + Assert.Equal("Highlight", inventory.ActiveState); + Assert.Equal(0x06004CF8u, DrawnFaceFile(inventory)); + } + + [InstalledDatFact] + [Trait("Lane", "InstalledDat")] + public void InstalledDat_inventoryButtonPressStateKeepsTheCurrentFace() + { + string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new AcDream.App.Tests.BoundedTestDatCollection( + datDirectory, + DatReaderWriter.Options.DatAccessType.Read); + ElementInfo root = Assert.IsType( + LayoutImporter.ImportInfos(dats, 0x21000016u)); + ElementInfo inventory = FindInfo(root, InventoryButtonId); + + Assert.Equal(1, inventory.States[UiButtonStateMachine.Normal].MediaCount); + Assert.Equal(1, inventory.States[UiButtonStateMachine.Normal].ImageMediaCount); + Assert.Equal(1, inventory.States[UiButtonStateMachine.NormalPressed].MediaCount); + Assert.Equal(0, inventory.States[UiButtonStateMachine.NormalPressed].ImageMediaCount); + Assert.Equal(1, inventory.States[UiButtonStateMachine.Highlight].MediaCount); + Assert.Equal(1, inventory.States[UiButtonStateMachine.Highlight].ImageMediaCount); + Assert.DoesNotContain("Normal_pressed", inventory.StateMedia.Keys); + } + [Fact] public void RetailFixture_panelButtonsExposeExactDatPanelIds() { @@ -1526,4 +1593,39 @@ public class ToolbarControllerTests iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 } + + private static uint DrawnFaceFile(UiButton button) + { + var renderer = new AcDream.App.Rendering.TextRenderer( + new AcDream.App.Tests.Rendering.Gpu.RecordingGpuDevice(), + new NullGpuFrameSource(), + "unused"); + renderer.Begin(new System.Numerics.Vector2(200f, 200f)); + button.DrawSelfAndChildren(new UiRenderContext( + renderer, + new System.Numerics.Vector2(200f, 200f))); + return Assert.Single(renderer.DebugSpriteSegments).Texture; + } + + private sealed class NullGpuFrameSource + : AcDream.App.Rendering.ICurrentGpuFrameSource + { + public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null; + } + + private static ElementInfo FindInfo(ElementInfo root, uint id) + => TryFindInfo(root, id) + ?? throw new InvalidOperationException($"Element 0x{id:X8} was not found."); + + private static ElementInfo? TryFindInfo(ElementInfo root, uint id) + { + if (root.Id == id) + return root; + foreach (ElementInfo child in root.Children) + { + if (TryFindInfo(child, id) is { } found) + return found; + } + return null; + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json index 5fe52392..308a2aa8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json @@ -1163,6 +1163,7 @@ "File": 100693175, "DrawMode": 3 }, + "MediaCount": 1, "Cursor": null, "Properties": { "Values": {} @@ -1177,6 +1178,7 @@ "File": 100693176, "DrawMode": 3 }, + "MediaCount": 1, "Cursor": null, "Properties": { "Values": {} @@ -1269,4 +1271,4 @@ "LedCheckedSprite": 0, "LedUncheckedSprite": 0, "ScrollbarElementId": 0 -} \ No newline at end of file +} diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json index aa91c0a9..f158718d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json @@ -6569,6 +6569,8 @@ "Name": "Normal_pressed", "PassToChildren": false, "IncorporationFlags": 0, + "MediaCount": 1, + "ImageMediaCount": 0, "Image": null, "Cursor": null, "Properties": { @@ -6580,6 +6582,8 @@ "Name": "Normal", "PassToChildren": false, "IncorporationFlags": 0, + "MediaCount": 1, + "ImageMediaCount": 1, "Image": { "File": 100682999, "DrawMode": 3 @@ -6594,6 +6598,8 @@ "Name": "Highlight", "PassToChildren": false, "IncorporationFlags": 0, + "MediaCount": 1, + "ImageMediaCount": 1, "Image": { "File": 100683000, "DrawMode": 3 @@ -12574,4 +12580,4 @@ "LedCheckedSprite": 0, "LedUncheckedSprite": 0, "ScrollbarElementId": 0 -} \ No newline at end of file +} diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs index ca9554f7..4a81e5ce 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -334,7 +334,7 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable } [Fact] - public void RestoreAll_WithoutSaveBack_WritesNothingToTheStore() + public void RestoreAfterDisplayChange_WritesNothingToTheStore() { // The live display-change reload (#390) must not persist — retail // saves layouts only via @saveui/@saveautoui, and a mid-drag reload @@ -346,19 +346,70 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable using var persistence = new RetailWindowLayoutPersistence( root.WindowManager, store, () => "Alice", () => (800, 600)); - persistence.RestoreAll(saveBack: false); + persistence.RestoreAfterDisplayChange(); Assert.Null(store.LoadWindowLayout( "Alice", "800x600", WindowNames.Examination, default)); } + [Fact] + public void RestoreAfterDisplayChange_RestoresGeometryButPreservesLiveVisibility() + { + // Retail's global-message-0xE auto-layout file contains only X/Y/W/H. + // Our richer ambient settings schema also remembers visibility and + // collapsed/maximized state, but replaying visibility during a + // resolution change used to hide an open Options panel. Config's OnHidden reset + // then restored the previous resolution, creating the user-observed + // resize-out/resize-back blip. + var store = new SettingsStore(PathName); + store.SaveWindowLayout( + "Alice", + "1280x720", + WindowNames.Options, + new UiWindowLayout( + X: 700f, + Y: 200f, + Width: 310f, + Height: 400f, + Visible: false, + Collapsed: true, + Maximized: true)); + + var root = new UiRoot { Width = 1280, Height = 720 }; + var state = new FakeWindowState(); + var lifecycle = new FakePanelController(); + RetailWindowHandle handle = Mount( + root, + WindowNames.Options, + state, + controller: lifecycle, + width: 300f, + height: 300f); + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, + store, + () => "Alice", + () => (1280, 720)); + + persistence.RestoreAfterDisplayChange(); + + Assert.Equal((700f, 200f, 310f, 400f), + (handle.Left, handle.Top, handle.Width, handle.Height)); + Assert.True(handle.IsVisible); + Assert.Equal(0, lifecycle.HiddenCount); + Assert.Equal(1, state.RestoreCount); + Assert.True(state.Restored.Collapsed); + Assert.True(state.Restored.Maximized); + } + private static RetailWindowHandle Mount( UiRoot root, string name, IRetainedWindowStateController? state = null, int authoredGeometryRevision = 0, float width = 200f, - float height = 100f) + float height = 100f, + IRetainedPanelController? controller = null) { var frame = new UiPanel { @@ -377,6 +428,7 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable return root.RegisterWindow( name, frame, + controller: controller, stateController: state, authoredGeometryRevision: authoredGeometryRevision); } @@ -389,7 +441,23 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable private sealed class FakeWindowState : IRetainedWindowStateController { public RetainedWindowState Restored { get; private set; } + public int RestoreCount { get; private set; } public RetainedWindowState CaptureWindowState() => Restored; - public void RestoreWindowState(RetainedWindowState state) => Restored = state; + public void RestoreWindowState(RetainedWindowState state) + { + Restored = state; + RestoreCount++; + } + } + + private sealed class FakePanelController : IRetainedPanelController + { + public int HiddenCount { get; private set; } + + public void OnShown() { } + + public void OnHidden() => HiddenCount++; + + public void Dispose() { } } } diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLockPresentationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLockPresentationControllerTests.cs new file mode 100644 index 00000000..a1c717a0 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailWindowLockPresentationControllerTests.cs @@ -0,0 +1,362 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; +using System.Numerics; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class RetailWindowLockPresentationControllerTests +{ + private static readonly uint[] LockedChatChromeIds = + [ + 0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u, + 0x10000697u, 0x10000698u, 0x10000699u, 0x1000069Au, + ]; + + private static readonly uint[] LiveChatChromeIds = + [ + 0x1000069Bu, 0x1000069Cu, 0x1000069Du, 0x1000069Eu, + 0x1000069Fu, 0x100006A0u, 0x100006A1u, 0x100006A2u, + ]; + + [Fact] + public void ExistingAndFutureNineSliceWindows_TrackCurrentLockPresentationExactly() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var existing = NewNineSlice(); + root.AddChild(existing); + root.RegisterWindow("existing", existing); + + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + + Assert.True(existing.DrawResizeAffordances); + Assert.True(existing.DrawCenterFill); + + root.UiLocked = true; + Assert.False(existing.DrawResizeAffordances); + Assert.True(existing.DrawCenterFill); + + var registeredWhileLocked = NewNineSlice(); + registeredWhileLocked.DrawCenterFill = false; + root.AddChild(registeredWhileLocked); + root.RegisterWindow("future", registeredWhileLocked); + + Assert.False(registeredWhileLocked.DrawResizeAffordances); + Assert.False(registeredWhileLocked.DrawCenterFill); + + root.UiLocked = true; + Assert.False(existing.DrawResizeAffordances); + Assert.False(registeredWhileLocked.DrawResizeAffordances); + + root.UiLocked = false; + Assert.True(existing.DrawResizeAffordances); + Assert.True(registeredWhileLocked.DrawResizeAffordances); + Assert.True(existing.DrawCenterFill); + Assert.False(registeredWhileLocked.DrawCenterFill); + } + + [Fact] + public void LockedNineSlice_DrawsCenterAndEightBevelPieces_ButNoEightGripOverlays() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var frame = NewNineSlice(); + root.AddChild(frame); + root.RegisterWindow("frame", frame); + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + + TextRenderer unlocked = Draw(frame); + Assert.Equal(17, SpriteCallCount(unlocked)); + Assert.Equal(8, GripCallCount(unlocked)); + + root.UiLocked = true; + TextRenderer locked = Draw(frame); + + Assert.Equal(9, SpriteCallCount(locked)); + Assert.Equal(0, GripCallCount(locked)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CenterFill)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.TopEdge)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.BottomEdge)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.LeftEdge)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.RightEdge)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerTL)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerTR)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerBL)); + Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerBR)); + } + + [Fact] + public void AuthoredChatChrome_SwapsLiveAndLockedSets_AndUnlockRestoresExactly() + { + var infos = FixtureLoader.LoadChatInfos(); + var layout = LayoutImporter.Build(infos, NoTex, null); + _ = ChatWindowController.Bind( + infos, + layout, + new ChatVM(new ChatLog()), + () => NullCommandBus.Instance, + new ChatWindowState(), + null, + null, + NoTex); + var root = new UiRoot { Width = 800, Height = 600 }; + root.AddChild(layout.Root); + root.RegisterWindow("chat", layout.Root, layout.Root); + + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + + AssertChrome(layout, liveVisible: true); + + root.UiLocked = true; + AssertChrome(layout, liveVisible: false); + + root.UiLocked = true; + AssertChrome(layout, liveVisible: false); + + root.UiLocked = false; + AssertChrome(layout, liveVisible: true); + + root.UiLocked = false; + AssertChrome(layout, liveVisible: true); + } + + [Fact] + public void AuthoredWindowRegisteredWhileLocked_StartsWithLockedChrome() + { + var root = new UiRoot { Width = 800, Height = 600, UiLocked = true }; + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + var infos = FixtureLoader.LoadChatInfos(); + var layout = LayoutImporter.Build(infos, NoTex, null); + _ = ChatWindowController.Bind( + infos, + layout, + new ChatVM(new ChatLog()), + () => NullCommandBus.Instance, + new ChatWindowState(), + null, + null, + NoTex); + + root.AddChild(layout.Root); + root.RegisterWindow("late-chat", layout.Root, layout.Root); + + AssertChrome(layout, liveVisible: false); + } + + [Fact] + public void FloatingChat_LockHidesAllResizeGrips_ButLeavesTitleDragElementVisible() + { + var layout = FixtureLoader.LoadFloatyChat(); + var root = new UiRoot { Width = 800, Height = 600 }; + root.AddChild(layout.Root); + root.RegisterWindow("floaty", layout.Root, layout.Root); + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + UiElement? titleDrag = layout.FindElement(0x10000529u); + Assert.NotNull(titleDrag); + UiResizeGrip[] grips = DescendantsAndSelf(layout.Root).OfType().ToArray(); + Assert.Equal(8, grips.Length); + Assert.All(grips, grip => Assert.True(grip.Visible)); + Assert.True(titleDrag.Visible); + + root.UiLocked = true; + + Assert.All(grips, grip => Assert.False(grip.Visible)); + Assert.True(titleDrag.Visible); + + root.UiLocked = false; + Assert.All(grips, grip => Assert.True(grip.Visible)); + Assert.True(titleDrag.Visible); + } + + [Fact] + public void AuthoredChrome_MissingPairMembersAndUnrelatedDecoration_TransitionSafely() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var frame = new UiPanel { Width = 200, Height = 100 }; + var lockedOnly = new UiPanel { DatElementId = 0x10000633u, Visible = false }; + var liveOnly = new UiPanel { DatElementId = 0x1000063Cu, Visible = true }; + var unrelated = new UiPanel { DatElementId = 0x10000632u, Visible = true }; + var initiallyHiddenLive = new UiPanel { DatElementId = 0x1000063Du, Visible = false }; + frame.AddChild(lockedOnly); + frame.AddChild(liveOnly); + frame.AddChild(unrelated); + frame.AddChild(initiallyHiddenLive); + root.AddChild(frame); + root.RegisterWindow("partial", frame); + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + + root.UiLocked = true; + Assert.True(lockedOnly.Visible); + Assert.False(liveOnly.Visible); + Assert.True(unrelated.Visible); + Assert.False(initiallyHiddenLive.Visible); + + root.UiLocked = false; + Assert.False(lockedOnly.Visible); + Assert.True(liveOnly.Visible); + Assert.True(unrelated.Visible); + Assert.False(initiallyHiddenLive.Visible); + } + + [Theory] + [InlineData(0x10000633u, 0x1000063Bu)] + [InlineData(0x10000643u, 0x1000064Bu)] + [InlineData(0x10000653u, 0x1000065Bu)] + [InlineData(0x10000663u, 0x1000066Bu)] + [InlineData(0x10000673u, 0x1000067Bu)] + [InlineData(0x10000683u, 0x1000068Bu)] + [InlineData(0x10000693u, 0x1000069Bu)] + [InlineData(0x100006A5u, 0x100006ADu)] + public void EveryRetailAuthoredChromeBlock_SwapsAllEightMembers( + uint lockedStart, + uint liveStart) + { + var root = new UiRoot { Width = 800, Height = 600 }; + var frame = new UiPanel { Width = 200, Height = 100 }; + UiElement[] lockedChrome = Enumerable.Range(0, 8) + .Select(i => new UiPanel { DatElementId = lockedStart + (uint)i }) + .Cast() + .ToArray(); + UiElement[] liveChrome = Enumerable.Range(0, 8) + .Select(i => new UiPanel { DatElementId = liveStart + (uint)i }) + .Cast() + .ToArray(); + foreach (UiElement element in lockedChrome.Concat(liveChrome)) + frame.AddChild(element); + root.AddChild(frame); + root.RegisterWindow("authored", frame); + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + + Assert.All(lockedChrome, element => Assert.False(element.Visible)); + Assert.All(liveChrome, element => Assert.True(element.Visible)); + + root.UiLocked = true; + Assert.All(lockedChrome, element => Assert.True(element.Visible)); + Assert.All(liveChrome, element => Assert.False(element.Visible)); + + root.UiLocked = false; + Assert.All(lockedChrome, element => Assert.False(element.Visible)); + Assert.All(liveChrome, element => Assert.True(element.Visible)); + } + + [Fact] + public void SmartBoxLiveOnlyChrome_HidesAndRestores_WithoutTouchingOtherType2OrType3Decoration() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var frame = new UiPanel { Width = 200, Height = 100 }; + UiElement[] smartBoxChrome = Enumerable.Range(0, 8) + .Select(i => new UiPanel { DatElementId = 0x100006CAu + (uint)i }) + .Cast() + .ToArray(); + var unrelatedType2 = new UiPanel { DatElementId = 0x10000529u }; + var unrelatedType3 = new UiPanel { DatElementId = 0x100006D2u }; + foreach (UiElement chrome in smartBoxChrome) + frame.AddChild(chrome); + frame.AddChild(unrelatedType2); + frame.AddChild(unrelatedType3); + root.AddChild(frame); + root.RegisterWindow("smartbox", frame); + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + + root.UiLocked = true; + Assert.All(smartBoxChrome, chrome => Assert.False(chrome.Visible)); + Assert.True(unrelatedType2.Visible); + Assert.True(unrelatedType3.Visible); + + root.UiLocked = false; + Assert.All(smartBoxChrome, chrome => Assert.True(chrome.Visible)); + Assert.True(unrelatedType2.Visible); + Assert.True(unrelatedType3.Visible); + } + + [Fact] + public void LockedLateRegistration_AppliesChromeBeforeControllerOnShown() + { + var root = new UiRoot { Width = 800, Height = 600, UiLocked = true }; + using var presentation = new RetailWindowLockPresentationController(root.WindowManager); + var frame = NewNineSlice(); + root.AddChild(frame); + var observer = new OnShownObserver(() => !frame.DrawResizeAffordances); + + root.RegisterWindow("late", frame, frame, observer); + + Assert.True(observer.ObservedLockedChrome); + Assert.Equal(1, observer.ShownCount); + } + + private static UiNineSlicePanel NewNineSlice() => + new(static id => (id, 5, 5)) { Width = 200, Height = 100 }; + + private static TextRenderer Draw(UiElement element) + { + var renderer = new TextRenderer(new RecordingGpuDevice(), new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + element.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(800f, 600f))); + return renderer; + } + + private static int SpriteCallCount(TextRenderer renderer) => + renderer.DebugSpriteSegments.Sum(segment => segment.VertexCount / 6); + + private static int CallsFor(TextRenderer renderer, uint texture) => + renderer.DebugSpriteSegments + .Where(segment => segment.Texture == texture) + .Sum(segment => segment.VertexCount / 6); + + private static int GripCallCount(TextRenderer renderer) => + CallsFor(renderer, RetailChromeSprites.GripTop) + + CallsFor(renderer, RetailChromeSprites.GripBottom) + + CallsFor(renderer, RetailChromeSprites.GripLeft) + + CallsFor(renderer, RetailChromeSprites.GripRight) + + CallsFor(renderer, RetailChromeSprites.GripCorner); + + private static IEnumerable DescendantsAndSelf(UiElement root) + { + yield return root; + foreach (UiElement child in root.Children) + foreach (UiElement descendant in DescendantsAndSelf(child)) + yield return descendant; + } + + private static void AssertChrome(ImportedLayout layout, bool liveVisible) + { + foreach (uint id in LiveChatChromeIds) + { + UiElement? element = layout.FindElement(id); + Assert.NotNull(element); + Assert.Equal(liveVisible, element.Visible); + } + foreach (uint id in LockedChatChromeIds) + { + UiElement? element = layout.FindElement(id); + Assert.NotNull(element); + Assert.Equal(!liveVisible, element.Visible); + } + } + + private static (uint tex, int width, int height) NoTex(uint _) => (0u, 0, 0); + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private sealed class OnShownObserver(Func observe) : IRetainedPanelController + { + public int ShownCount { get; private set; } + public bool ObservedLockedChrome { get; private set; } + + public void OnShown() + { + ShownCount++; + ObservedLockedChrome = observe(); + } + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs index 638eecf7..476ecf19 100644 --- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs +++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs @@ -1,5 +1,6 @@ using AcDream.Core.Items; using AcDream.Core.Player; +using AcDream.Core.Properties; using AcDream.Core.Spells; namespace AcDream.Core.Tests.Player; @@ -170,6 +171,123 @@ public sealed class LocalPlayerStateTests Assert.Equal(1f, s.StaminaPercent!.Value); } + [Fact] + public void GetMaxApprox_PrimaryAttributeModifierWithCollidingKeyDoesNotAffectHealth() + { + // Regression: PropertyAttribute.Strength and + // PropertyAttribute2nd.MaxHealth both use numeric key 1. The HUD's + // GetVitalMod path previously omitted retail's SecondAtt domain + // filter, so a near-identity Strength modifier reproduced the live + // report exactly: 99999 current / 99998 max. + var book = new Spellbook(SpellTable.Create([TestSpell(1u)])); + book.OnEnchantmentAdded(new ActiveEnchantmentRecord( + SpellId: 1u, + LayerId: 1u, + Duration: 60d, + CasterGuid: 0u, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute, + StatModKey: 1u, + StatModValue: 0.99999f, + Bucket: 1u)); + var s = new LocalPlayerState(book); + s.OnVitalUpdate( + vitalId: 7u, + ranks: 99_999u, + start: 0u, + xp: 0u, + current: 99_999u); + + Assert.Equal(99_999u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health)); + Assert.Equal(99_999u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health)); + Assert.Equal(1f, s.HealthPercent); + } + + [Fact] + public void GetMaxApprox_SecondaryAttributeModifierTruncatesLikeRetail() + { + // CEnchantmentRegistry::EnchantAttribute2nd ends in _ftol2. A + // fractional secondary-attribute result is truncated, not rounded. + var book = new Spellbook(SpellTable.Create([TestSpell(1u)])); + book.OnEnchantmentAdded(new ActiveEnchantmentRecord( + SpellId: 1u, + LayerId: 1u, + Duration: 60d, + CasterGuid: 0u, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt, + StatModKey: EnchantmentMath.StatKey.MaxHealth, + StatModValue: 0.75f, + Bucket: 2u)); + var s = new LocalPlayerState(book); + s.OnVitalUpdate( + vitalId: 7u, + ranks: 100u, + start: 0u, + xp: 0u, + current: 100u); + + Assert.Equal(100u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health)); + } + + [Fact] + public void GetMaxApprox_PrimaryAttributeBuffsFeedVitalFormula_ExactLiveRegression() + { + // Live screenshot regression: the server-authoritative currents were + // 38/75/25 while the HUD showed the raw-formula maxima 30/60/10. + // Retail InqAttribute2nd evaluates Endurance/Self through enchanted + // InqAttribute before applying the separate secondary-attribute mod. + var book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)])); + book.OnEnchantmentAdded(new ActiveEnchantmentRecord( + SpellId: 1u, + LayerId: 1u, + Duration: 60d, + CasterGuid: 0u, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute, + StatModKey: 2u, // Endurance + StatModValue: 15f, + Bucket: 2u)); + book.OnEnchantmentAdded(new ActiveEnchantmentRecord( + SpellId: 2u, + LayerId: 2u, + Duration: 60d, + CasterGuid: 0u, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute, + StatModKey: 6u, // Self + StatModValue: 15f, + Bucket: 2u)); + var s = new LocalPlayerState(book); + s.OnAttributeUpdate(atType: 2u, ranks: 0u, start: 30u, xp: 0u); + s.OnAttributeUpdate(atType: 6u, ranks: 0u, start: 10u, xp: 0u); + s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 15u, xp: 0u, current: 38u); + s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 30u, xp: 0u, current: 75u); + s.OnVitalUpdate(vitalId: 9u, ranks: 0u, start: 0u, xp: 0u, current: 25u); + + Assert.Equal(30u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health)); + Assert.Equal(60u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Stamina)); + Assert.Equal(10u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Mana)); + Assert.Equal(38u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health)); + Assert.Equal(75u, s.GetMaxApprox(LocalPlayerState.VitalKind.Stamina)); + Assert.Equal(25u, s.GetMaxApprox(LocalPlayerState.VitalKind.Mana)); + Assert.Equal(1f, s.HealthPercent); + Assert.Equal(1f, s.StaminaPercent); + Assert.Equal(1f, s.ManaPercent); + } + + [Fact] + public void GetMaxApprox_HealthRoundsHalfEnduranceAndIncludesGearMaxHealth() + { + // SkillFormula::Calculate @ 0x00591960 rounds 45/2 to 23, and + // InqAttribute2nd @ 0x00592020 adds property 379 before enchantment. + var s = new LocalPlayerState(); + var properties = new PropertyBundle(); + properties.Ints[(uint)PropertyInt.GearMaxHealth] = 7; + s.OnProperties(properties); + s.OnAttributeUpdate(atType: 2u, ranks: 0u, start: 45u, xp: 0u); + s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 10u, xp: 0u, current: 40u); + + Assert.Equal(40u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health)); + Assert.Equal(40u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health)); + } + [Fact] public void OnVitalCurrent_UpdatesOnlyCurrent_LeavesRanksStartXpAlone() { diff --git a/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs index bacad3b3..9afc77b5 100644 --- a/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs +++ b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs @@ -103,6 +103,7 @@ public sealed class SpellbookTests LayerId: 7u, Duration: 300f, CasterGuid: 0u, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt, StatModKey: EnchantmentMath.StatKey.MaxHealth, StatModValue: 1.5f, Bucket: 1u)); @@ -134,6 +135,7 @@ public sealed class SpellbookTests LayerId: 7u, Duration: 300f, CasterGuid: 0u, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt, StatModKey: EnchantmentMath.StatKey.MaxHealth, StatModValue: 1.25f, Bucket: 1u)); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index ad9c4c9b..b577b92b 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -94,7 +94,7 @@ public sealed class RuntimeCharacterStateTests Duration: 60f, CasterGuid: 2u, Bucket: 2u, - StatModType: 0u, + StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt, StatModKey: EnchantmentMath.StatKey.MaxHealth, StatModValue: 25f)); state.Spellbook.SetDesiredComponent(0x68000001u, 12u);