diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1c50ec6c..307ebde0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,53 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #408 — General importer-wide honor of dat property 0x3B (Invisible) is unshipped (1,083 elements client-wide) + +**Status:** OPEN +**Severity:** LOW-MEDIUM (cosmetic — extra/leaked elements render where retail hides them; no gameplay/wire impact) + +Found while fixing GF-13 (Campaign CC gate round 1, Batch A, 2026-08-16): +acdream's `LayoutImporter`/`DatWidgetFactory` never read dat property +`0x3B` (Invisible — `BoolBaseProperty`), which retail's +`UIElement::OnSetAttribute @0x00462d80` case 8 +(`GetPropertyName()-0x33==8`) honors on EVERY element via +`SetVisible(value==0)`. The blast-radius sweep this fix's investigation +ran found **1,083 elements client-wide** author `P0x3B=true` — far +beyond the two chargen-Summary GM labels (`0x10000403` +"Non-Admin"/`0x10000494` "Non-Envoy") the user actually reported. + +The fix (`fix(chargen): Campaign CC gate round 1 Batch A`) added the data +plumbing everywhere (`ElementInfo.Invisible`, read in +`ElementReader.ApplyCanonicalLegacyProjection`; `UiElement.AuthoredInvisible`, +set in `LayoutImporter.BuildWidget`) but deliberately does NOT act on it +in the shared importer path — only `CharacterCreationUiController` +(`HideAuthoredInvisibleElements`) walks its own mounted subtree and +hides what it finds, chargen-scoped only. Register row AP-230 records +the split. + +Honoring the flag client-wide (setting `UiElement.Visible = false` +directly in `LayoutImporter.BuildWidget` when `info.Invisible` is true, +or an equivalent central chokepoint) is straightforward, but 1,083 +elements is its own visual-regression surface: any one of them could be +an element some OTHER screen currently relies on being visible despite +authoring the flag (e.g. a state-conditional visibility toggle that +happens to leave `0x3B=true` on its default/direct state while a +controller separately manages `Visible` at runtime). This needs its own +sweep — dump the 1,083 ids grouped by owning LayoutDesc/screen, spot-check +a representative sample per screen against retail, then flip the +importer-wide switch with a dedicated visual gate — not a one-line +change folded into an unrelated fix. + +Fix direction: (1) enumerate the 1,083 ids per LayoutDesc (a live-DAT +probe test, similar to `SpewBoxLayoutDumpDiagnostic`); (2) for each +distinct screen/LayoutDesc, confirm honoring the flag doesn't hide +something the runtime currently manages visibility of dynamically at that +SAME element id (would double-drive `Visible`); (3) flip the honor in +`LayoutImporter.BuildWidget` (mirroring the chargen-scoped code path +already proven live) and delete `CharacterCreationUiController`'s own +narrow `HideAuthoredInvisibleElements`/AP-230 in the same commit; (4) run +a full-client visual matrix, not just chargen. + ## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating) **Status:** DONE (`e601a496`, 2026-08-16 — same gate round, user-directed immediate fix) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ffebb196..1b3b3da7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 163 active rows (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) — 164 active rows (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 @@ -207,6 +207,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-230 | **Filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13).** Retail's `UIElement::OnSetAttribute @0x00462d80` case 8 (`GetPropertyName()-0x33==8`, property id `0x3B`, "Invisible") hides ANY element authoring that property `true` via `SetVisible(value==0)` — a general, importer-level mechanism. The blast-radius sweep this fix's investigation ran found **1,083 elements client-wide** author `P0x3B=true` (the Summary page's GM-only `0x10000403`/`0x10000494` labels among them — the user-reported "-Non-admin or Non-envoy" leak). Honoring the flag client-wide in `LayoutImporter`/`DatWidgetFactory` is its own separately-gated visual sweep (docs/ISSUES.md #408, since a mis-hidden element among 1,083 untested ones would silently vanish a control nobody asked to disappear); this fix instead reads the flag as a PURE DATA ADDITION (`ElementInfo.Invisible`, `UiElement.AuthoredInvisible` — populated everywhere, acted on nowhere by the shared path) and only the chargen screen's own mount (`CharacterCreationUiController.HideAuthoredInvisibleElements`, called once at construction) walks its own subtree and hides whatever the dat itself marked hidden. | `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.Invisible`, `ApplyCanonicalLegacyProjection`'s `0x3Bu` read); `src/AcDream.App/UI/UiElement.cs` (`AuthoredInvisible`); `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget`'s passthrough assignment); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`HideAuthoredInvisibleElements`) | The scoped fix closes the ONE reported, live-DAT-confirmed symptom (chargen's two GM labels) without touching any of the other 1,083 elements' visibility, each of which needs its OWN visual gate before the general importer-wide honor can ship safely — narrowing blast radius to a screen this same gate round is already re-testing end-to-end. | Every OTHER screen with an authored-invisible element still renders it (the general honor is #408, not yet shipped) — this row and #408 both retire together once the general sweep lands and passes its own visual gate. | `UIElement::OnSetAttribute @0x00462d80` (case 8, `SetVisible(value==0)`) | | AP-229 | **Filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1.** Retail does NOT stack screens: `UIFlow::QueueUIMode @0x004793c0` sets `_nextMode`, then `UIFlow::UseNewMode @0x004796a0` calls `_curUI->vtable->Show(0)` on the current framework, immediately DESTROYS it (`_curUI->vtable->__vecDelDtor(1)`), constructs the new framework, and calls `Show(1)` on it — so retail TEARS DOWN `gmCharacterManagementUI` the instant Create fires and RE-CONSTRUCTS it when Exit confirms (Exit-confirm's `RecvNotice_CloseDialog @0x004e9883-0x004e989c` issues `QueueUIMode(0x1000000a)`, the reverse transition). acdream's CC7 instead keeps BOTH `CharacterManagementUiController` and `CharacterCreationUiController` mounted as permanent siblings under the shared `Host.Root` and only reveals/occludes them (`Root.Visible` + `_host.BringToFront(Root)`) — this was already true since the CC4 FixedCanvas-arbiter work, but CC7 made it the production Create/Exit path rather than a dev-only shortcut. **Confirmed working within this narrower surface:** selection/world-name persistence across the round trip is retail-faithful (retail's own `UIPersistantData::m_iidSelectedAvatar`, `UIPersistantData::UIPersistantData @0x00479a00`, persists exactly this data across the destroy/reconstruct — acdream gets the same outcome for free by never tearing the screen down at all); input cannot bleed from the visible chargen screen through to the occluded management screen underneath (chargen's `Root.ClickThrough = false` over the full authored canvas, plus a `_host.BringToFront(Root)` call every tick chargen is open, keeps it strictly on top and input-opaque); and the two controllers share ONE `RetailDialogFactory` instance (`RetailUiRuntime.EnsureDialogFactory`), so `UiRoot.Modal` stays a single coherent stack instead of two independent ones. **Residual risk the reviewer named:** because character-management is never deactivated while chargen sits on top of it, its own `ReconcileDialogs` keeps running every tick (`CharacterManagementUiController.cs:663-667`'s `if (snapshot.Error is { } error)` arm) and can call `EnsureError` → `_dialogs.MakeMessage(...)` on the SAME shared factory chargen uses. `RetailDialogFactory.RefreshModal` (`RetailDialogFactory.cs:587`, `_host.Modal = _openOrder[^1].View?.Root`) always promotes the most-recently-opened dialog to `Modal` — an inbound `CharacterError` reaching the occluded management screen while chargen is the visible, active screen could take `UiRoot.Modal` away from chargen and hand it to a dialog owned by the screen underneath. Retail cannot have this race by construction: character-management's C++ object no longer exists once Create fires, so there is nothing left to receive a stray inbound event. | `src/AcDream.App/UI/RetailUiRuntime.cs:3845-3847` (`ConfigureCharacterManagement`'s cross-screen `RequestCreate` seam, both controllers mounted as permanent siblings); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:465-473` (`Tick`'s reveal/occlude, not destroy/reconstruct); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:265` (`Root.ClickThrough = false`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs:663-672` (`ReconcileDialogs`' `snapshot.Error` arm, still ticking underneath); `src/AcDream.App/UI/Layout/RetailDialogFactory.cs:587` (`RefreshModal`, the shared `Modal` stack) | Both screens existing as permanent siblings is deliberately simpler than a byte-port of retail's destroy/reconstruct lifecycle (no framework-factory table, no `Show`/`__vecDelDtor` lifecycle to replicate), and every observable behavior a user can drive through the ordinary UI today matches retail (selection persists, input doesn't bleed, dialogs stay single-stacked) — the residual is a narrow, not-yet-observed race on a specific inbound-error timing, not a general design flaw. | If an inbound `CharacterError` lands on the character-management channel while chargen is the visible, focused screen, `UiRoot.Modal` could flip to a dialog owned by the occluded screen underneath, stealing input from the still-visible chargen screen — a state retail cannot reach because the occluded screen simply does not exist there. | `UIFlow::QueueUIMode @0x004793c0`; `UIFlow::UseNewMode @0x004796a0` (`Show(0)` → `__vecDelDtor(1)` → construct → `Show(1)`); `RecvNotice_CloseDialog @0x004e9883-0x004e989c` (Exit-confirm's `QueueUIMode(0x1000000a)`); `UIPersistantData::UIPersistantData @0x00479a00` (`m_iidSelectedAvatar`) | | AP-228 | **Filed 2026-08-16 at the CC5 re-review residual round (R4).** The Summary listbox's skill-row KEY (the skill's display name) sources from `ItemAppraisalTextFormatter.SkillName(int)` — a hardcoded English `switch` over the 54 skill ids — where retail's own `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` builds that same key from the DAT-sourced `SkillBase->_name` field via a `%hs` format substitution (`data_79f3f0`, `0x0047b90f`-`0x0047b915`). Same divergence CLASS as AP-226 (a hardcoded acdream string standing in for a DAT-sourced retail field) but the polarity is REVERSED: AP-226 is retail-static-vs-acdream-DAT-sourced, while here retail is the DAT-sourced side and acdream is the hardcoded side. The identical pattern is ALSO present at a second call site, CC4's Skills page (`CharacterCreationSkillsPage`), which builds its own row labels through the SAME `ItemAppraisalTextFormatter.SkillName` call — not a second, independent divergence, the same one surfacing twice. | `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs` (`SkillName`), consumed by `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`AddSkillBucket`) and `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` | `SkillName` already backs every OTHER retail skill-name surface acdream has shipped (item-appraisal skill lines, wield-requirement text, usage-limit text — `ItemAppraisalTextFormatter`'s whole existing surface) — the Summary/Skills chargen pages reusing it keeps one skill-name source across the client instead of introducing a second, DAT-reading one for chargen alone. English-only is consistent with the rest of the client's current localization posture (no other surface reads a localized skill name from the DAT either). | A non-English or modded DAT install would show its real, localized skill names on retail's character sheet and item-examine windows but acdream's chargen Summary/Skills pages would keep showing the hardcoded English name regardless — a localization-only divergence, never a wire or gameplay difference (the skill id sent over the wire is unaffected). | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` (`data_79f3f0`, `%hs` substitution `0x0047b90f`-`0x0047b915`) | | AP-227 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F9 (the Summary name field's empty-commit behavior).** Byte-decoded `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93`): the length field it reads is NUL-inclusive (an empty field's length is 1 — the SAME finding AP-225's retirement/AP-226 both cite), and the WHOLE commit block — the `>32` check, `CharGenState::SetName`, AND `DoNameLimitDialog` — sits behind `if (length != 1)`. Blurring an EMPTIED field in retail is therefore a complete no-op: `CharGenState.name` stays whatever it held before, and the field visually shows empty while the internal name (what `DoFinish` actually sends) does not change. `CharacterCreationSummaryPage.CommitNameFromField` instead calls `SetName` unconditionally, including for an empty commit — the state always matches what the field just showed. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`CommitNameFromField`) | Porting the exact skip was evaluated and rejected: it would fight `Refresh`'s own field-sync block (the F1 fix) — the NEXT unrelated Runtime revision bump (e.g. changing an attribute on another page, then returning to Summary) would see `field.Text ("") != snapshot.Name (the stale unchanged name)` and forcibly restore the OLD name into the emptied field, a spontaneous repopulation retail's own non-continuously-refreshed UI never produces. Always-clearing avoids that new failure mode at the cost of retail's exact one-frame field/state divergence. | A pixel-level side-by-side against retail would show: blur an emptied field, don't retype, click Finish — retail creates the character under the OLD (uncleared) name; acdream shows the `NoNameWarning` dialog instead (state genuinely empty). A narrow, one-interaction-wide behavioral difference, never silent (both paths produce a visible outcome, just a different one). | `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93` length gate, `~0x0047bfb1` the gated block); `CharGenState::SetName` | @@ -403,7 +404,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | | AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | -| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | +| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 18a3184f..2fb92a5a 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -15,20 +15,90 @@ ISSUES.md; this doc is the six-page batch. description text that would confirm is itself broken, GF-2). ALSO: the open-roll's own rolled heritage shows NO lit dot on entry — every dot dark in the acdream screenshot. -- **GF-5 Skills page empty.** Nothing renders except the screen-description - textbox — no skill rows, no credits display. (CC5's residual round wired - `TemplateResolver` into the SKILLS page too — yet live rows are absent.) +- **GF-5 Skills page empty — FIXED (Campaign CC gate round + 1, Batch A).** Root cause was `CharacterCreationSkillsPage.RebuildRows` + resolving `Templates[0]` (retail's own 3-child bucket-HEADER row, + `0x100002F4`) instead of `Templates[1]` (the REAL skill row, + `0x100002FF`, live-DAT-probe-confirmed 7 children) and requiring the + resolved root to be a `UiButton` (it's a plain container). Byte-traced + against `gmCGSkillsPage::DoSkillRecords @0x004817e0` + + `tagSkillRecord`'s copy-constructor field order to map every child id: + name (`0x10000301`), `pSkillLevelText` (`0x10000302`), `pUpCostText` + (`0x10000303`), `pSkillUpButton` (`0x10000304`), `pSkillDownButton` + (`0x10000305`), `pDownCostText` (`0x10000306`). Fixed to resolve + `Templates[1]`, wire the real per-row up/down arrow buttons to + `ListenToElementMessage @0x004814c0`'s own plain-click dispatch + (`IncreaseSkillLevel`/`DecreaseSkillLevel`), retiring AP-213's click-to- + advance/double-click-retreat single-button substitution (narrowed, not + fully retired — the flat-list-vs-four-bucket half stays). The credits- + caption clobber (`SkillsPage.cs:81-82`, now different line numbers) is + UNCHANGED — Batch C's scope. - **GF-9 Appearance color swatches do nothing observable.** Clicking a color produces no visible change (model recolor absent). Could be a dead dispatch or could be working-but-invisible (AP-216 authored-art swatches + a recolor that fails); investigate, don't guess. - **GF-11a Town description text does not change** when switching towns. -- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name** — an - acdream-only text leak; retail's summary list has no such rows. -- **GF-15 Summary name entry DEAD + Finish unpressable.** Cannot type into - the name field at all; Finish cannot be pressed. Also the field is not - prefilled with retail's `[ Name` placeholder. This blocks the entire - create flow — the gate cannot proceed past Summary. +- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED + (Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat + property `0x3B` (Invisible — `UIElement::OnSetAttribute @0x00462d80` + case 8) was never read by the importer at all; elements `0x10000403` + ("Non-Admin") and `0x10000494` ("Non-Envoy") both author it `true` + (live-DAT-probe-confirmed, path `0x100003CC > 0x100003D0 > 0x100003D6 > + {0x10000403,0x10000494}`). Blast-radius sweep found **1,083 elements + client-wide** author the same flag — a blanket importer-wide honor is + its own visual gate, filed as ISSUES.md #408. This fix is CHARGEN-SCOPED + ONLY: `ElementInfo.Invisible`/`UiElement.AuthoredInvisible` are pure + data additions (read/stored everywhere, acted on nowhere by the shared + importer path), and `CharacterCreationUiController.HideAuthoredInvisibleElements` + walks its own mounted subtree once at construction and hides whatever + the dat itself marked hidden — by the authored flag, not a hardcoded id + list. Register AP-230 records the scoped-vs-general split. +- **GF-15 Summary name entry DEAD + Finish unpressable — FIXED + (Campaign CC gate round 1, Batch A) — this commit, LIVE-VERIFIED end to + end.** The live-repro investigation (offline `ACDREAM_OPEN_CHARGEN=1` + alone does NOT open the chargen screen — `RuntimeCharacterCreationState` + only activates via `LiveSessionController.StartAsync`'s authenticated- + connect path, `LiveSessionController.cs:791`; the repro required a real + connect to the project's own local ACE test server) showed the FIRST + click into the name field correctly focuses it and typing correctly + lands characters — the modal/pick/focus mechanics the earlier static + investigation examined were never broken. The REAL cause only surfaces + after the FIRST dialog opens: pressing Finish with an empty name + successfully creates the NoName `RetailMessageDialogView` + (`visible=true`, live-DAT-probe-confirmed nonzero popup/message/button + geometry — 400x95 popup, correctly centered) but it renders NOTHING and + silently absorbs every subsequent click across the WHOLE canvas, + including clicks aimed at the name field or Finish button underneath. + Root cause: `CharacterCreationUiController.Tick()` (and + `CharacterManagementUiController.Tick()`) call `UiRoot.BringToFront(Root)` + UNCONDITIONALLY every frame while their screen is open (needed so + chargen stays above the occluded character-management screen + underneath, register AP-229); a dialog's root is a direct sibling of + those screen roots under the same `UiRoot`, and + `RetailWindowManager.BringToFront` is a simple "highest ZOrder among + siblings + 1" — whichever sibling's own `BringToFront` call runs LAST in + a frame wins. `RetailDialogFactory.Tick()` never re-asserted its own + open dialogs' z-order, so the VERY NEXT frame's screen `Tick()` (which + always runs before the dialog factory's own `Tick()` in + `RetailUiRuntime.Tick(double)`'s per-frame sequence) silently buried the + dialog behind the screen's opaque backdrop — while the dialog remained + the registered `UiRoot.Modal` and kept EXCLUSIVE input priority + (`OnMouseDown`'s Modal-vs-bounds gate is independent of render/z-order). + Fixed by having `RetailDialogFactory.Tick()` re-raise every open dialog + (in `_openOrder`, so the most recently opened stays topmost) every tick, + matching retail's real always-on-top dialog behavior. Live-verified the + COMPLETE user sequence after the fix: click name field (focuses), type + (lands), press Finish empty (NoName dialog now VISIBLY renders: "You + must enter a name for this character!"), click OK (dismisses cleanly, + `Modal` clears), click the field again (still focusable/typable). The + `[ Name` prefill question is CLOSED, not a bug: byte-verified neither + `CharGenState::RandomizeCharacter @0x005c6d80` nor + `gmCGSummaryPage::InitializePage @0x0047bbf0` ever write text into the + name field (`InitializePage` only sets the input filter) — retail's + field is genuinely code-empty on a freshly-rolled character, matching + acdream's existing (correct) behavior; the `[ Name` the user saw was + most likely the field's own bracket-style empty-state chrome (GF-2/GF-12 + textbox-decoration family), not a missing name-prefill feature. ## Presentation families (retail parity) @@ -72,8 +142,12 @@ ISSUES.md; this doc is the six-page batch. GF-10 zoom art) — the AP-222 measured mechanism (state media authored vs applied) across widget kinds. 4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws. -5. Input routing on Summary (GF-15) — focus/typing path on the stacked - chargen screen. +5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked + chargen screen.~~ CLOSED: focus/typing routing was never broken (live- + verified); the real cause was `RetailDialogFactory` never re-asserting + its open dialogs' z-order against the chargen/char-management screens' + own per-tick `BringToFront` — see GF-15's own entry above. Batch A + fixed it. ## Process diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index 8dcf2d43..1ab988c3 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -23,16 +23,86 @@ namespace AcDream.App.UI.Layout; /// ChargenTableReaderInstalledDatTests) are filtered out via the /// same two-tier presence check RuntimeCharacterCreationState's /// TryGetSkillCost uses. +/// +/// +/// GF-5 fix (Campaign CC gate round 1, Batch A, 2026-08-16): +/// RebuildRows used to require Templates[0]'s resolved root to +/// be a UiButton and treat its own Label as the row's whole content — +/// both wrong. Live-DAT-probe-confirmed against the installed EoR dat and +/// gmCGSkillsPage::DoSkillRecords @ 0x004817e0: +/// Templates[0] (0x100002F4, 3 children) is retail's own +/// bucket-HEADER row (Specialized/Trained/UseableUntrained/ +/// UnuseableUntrained — unused by this port's flat-list simplification, +/// AP-213), and the REAL skill row is Templates[1] +/// (0x100002FF, a plain container root, 7 children). Byte-traced +/// through DoSkillRecords' own GetChildRecursive calls + +/// tagSkillRecord's copy-constructor field order +/// (acclient.h struct gmCGSkillsPage::tagSkillRecord): +/// 0x10000301 = the skill NAME (set once at row build, never +/// refreshed — retail has no per-refresh name write either), +/// 0x10000302 = pSkillLevelText (the numeric skill SCORE, +/// CharGenState::GetSkillScore), 0x10000303 = +/// pUpCostText, 0x10000306 = pDownCostText, +/// 0x10000304 = pSkillUpButton (fires +/// IncreaseSkillLevel on plain click, +/// ListenToElementMessage @0x004814c0 case 0x10000304), +/// 0x10000305 = pSkillDownButton (fires +/// DecreaseSkillLevel, same dispatcher's case 0x10000305). +/// Both buttons fire on a PLAIN click, not click-vs-double-click on one +/// shared row — the row now wires exactly that, retiring AP-213's own +/// click-to-advance/double-click-retreat single-button substitution (the +/// row's still-simplified flat-list-vs-four-bucket half is untouched and +/// stays registered). +/// /// internal sealed class CharacterCreationSkillsPage : IDisposable { + /// Retail's own row-name id (set once at row build; retail + /// never re-writes it on refresh either — DoSkillRecords' + /// UIElement_Text::SetText(id_2, &var_138) at + /// 0x00481d5d runs OUTSIDE the per-refresh SetSkillText + /// call). + private const uint RowNameTextId = 0x10000301u; + + /// tagSkillRecord::pSkillLevelText — the numeric skill + /// SCORE (SetSkillText @0x00480600's + /// CharGenState::GetSkillScore call, "%d" format). + private const uint RowLevelTextId = 0x10000302u; + + /// tagSkillRecord::pUpCostText. + private const uint RowUpCostTextId = 0x10000303u; + + /// tagSkillRecord::pDownCostText. + private const uint RowDownCostTextId = 0x10000306u; + + /// tagSkillRecord::pSkillUpButton — + /// ListenToElementMessage's case 0x10000304 fires + /// IncreaseSkillLevel on a plain click (idMessage==1). + private const uint RowUpButtonId = 0x10000304u; + + /// tagSkillRecord::pSkillDownButton — same dispatcher's + /// case 0x10000305 fires DecreaseSkillLevel. + private const uint RowDownButtonId = 0x10000305u; + + /// One built skill row: the resolved Templates[1] + /// subtree plus the child widgets needs + /// every tick, resolved once at build time rather than re-walked per + /// refresh. + private readonly record struct SkillRow( + UiElement Root, + uint SkillId, + UiText? LevelText, + UiText? UpCostText, + UiText? DownCostText, + UiButton? UpButton, + UiButton? DownButton); + private readonly CharacterCreationRuntimeBindings _bindings; private readonly UiTemplateListBox? _list; private readonly UiButton? _credits; private readonly UiText? _infoTitle; private readonly UiText? _infoText; - private readonly List _rows = []; - private readonly Dictionary _rowSkillIds = []; + private readonly List _rows = []; private uint _lastHeritageId; private bool _rowsBuilt; private bool _disposed; @@ -55,6 +125,11 @@ internal sealed class CharacterCreationSkillsPage : IDisposable // faithful substitute is the button's own Label, which is exactly // the mechanism our factory already uses to surface a consumed // Type-12 child's text (register AD-103). + // + // GF-5 note: this clobbers the button's authored "Available Skill + // Credits" caption (retail's own m_pCreditsMeter is a SEPARATE + // widget from any caption text) — left as-is per the gate-round + // scope (Batch C owns the caption fix). _credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton; _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; @@ -71,12 +146,8 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _rowsBuilt = true; } - foreach (UiButton row in _rows) - { - if (!_rowSkillIds.TryGetValue(row, out uint skillId)) - continue; - row.Label = FormatSkillLabel(view, snapshot.HeritageId, skillId); - } + foreach (SkillRow row in _rows) + RefreshRowValues(row, view, snapshot); if (_credits is { } credits) credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); @@ -84,45 +155,94 @@ internal sealed class CharacterCreationSkillsPage : IDisposable private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) { - foreach (UiButton row in _rows) + foreach (SkillRow row in _rows) { - row.OnClick = null; - row.OnDoubleClick = null; + if (row.UpButton is not null) row.UpButton.OnClick = null; + if (row.DownButton is not null) row.DownButton.OnClick = null; } _rows.Clear(); - _rowSkillIds.Clear(); _list?.Flush(); if (_list is null - || _list.Templates.Count == 0 + || _list.Templates.Count < 2 || _list.TemplateResolver is null || !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) { return; } - UiTemplateListEntry template = _list.Templates[0]; + // Templates[1] (0x100002FF) is the REAL skill row — see this + // class's own doc comment for the full byte trace. + UiTemplateListEntry template = _list.Templates[1]; for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) { if (!IsCostable(heritage, view.Options, skillId)) continue; if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId) - is not UiButton row) + is not { } rowRoot) { continue; } - _list.AddPrebuiltRow(row); - row.Enabled = true; - row.SuppressSelfToggle = true; + _list.AddPrebuiltRow(rowRoot); + + if (UiElement.FindDescendant(rowRoot, RowNameTextId) is UiText nameText) + SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId)); + UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText; + UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText; + UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText; + UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton; + UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton; + uint capturedSkillId = skillId; - row.OnClick = () => Advance(capturedSkillId); - row.OnDoubleClick = () => Retreat(capturedSkillId); - _rows.Add(row); - _rowSkillIds[row] = skillId; + if (upButton is not null) + upButton.OnClick = () => Advance(capturedSkillId); + if (downButton is not null) + downButton.OnClick = () => Retreat(capturedSkillId); + + _rows.Add(new SkillRow( + rowRoot, skillId, levelText, upCostText, downCostText, upButton, downButton)); } } + private void RefreshRowValues( + SkillRow row, + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId); + (int trainedCost, int specializedCost) = GetCosts(view, snapshot.HeritageId, row.SkillId); + uint score = _bindings.GetSkillScore?.Invoke(row.SkillId, snapshot.Attributes, level) ?? 0u; + + if (row.LevelText is { } levelText) + SetLine(levelText, score.ToString(CultureInfo.InvariantCulture)); + + // SetSkillText @0x00480600's own per-state up/down cost pair: at + // Untrained, up=trainCost (down blank, nothing below Untrained); at + // Trained, up=(specCost-trainCost), down=trainCost; at Specialized, + // up=blank (nothing above Specialized), down=(specCost-trainCost). + // Retail also blanks a cost >= 999 (data_794320, an empty + // PStringBase) instead of showing the raw number. + (int? upCost, int? downCost) = level switch + { + ChargenSkillAdvancementClass.Specialized => + ((int?)null, (int?)(specializedCost - trainedCost)), + ChargenSkillAdvancementClass.Trained => + ((int?)(specializedCost - trainedCost), (int?)trainedCost), + _ => ((int?)trainedCost, (int?)null), + }; + if (row.UpCostText is { } upCostText) + SetLine(upCostText, FormatCost(upCost)); + if (row.DownCostText is { } downCostText) + SetLine(downCostText, FormatCost(downCost)); + } + + private static string FormatCost(int? cost) => + cost is int c && c < 999 ? c.ToString(CultureInfo.InvariantCulture) : string.Empty; + + private static void SetLine(UiText text, string content) => + text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)]; + /// Same dictionary-presence gate as /// RuntimeCharacterCreationState.TryGetSkillCost — heritage list /// first, global SkillTable fallback. @@ -133,19 +253,6 @@ internal sealed class CharacterCreationSkillsPage : IDisposable heritage.SkillCostsBySkillId.ContainsKey(skillId) || options.GlobalSkillCostsBySkillId.ContainsKey(skillId); - private string FormatSkillLabel( - IRuntimeCharacterCreationView view, - uint heritageId, - uint skillId) - { - string name = ItemAppraisalTextFormatter.SkillName((int)skillId); - ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId); - (int trainedCost, int specializedCost) = GetCosts(view, heritageId, skillId); - return string.Create( - CultureInfo.InvariantCulture, - $"{name}: {level} (T{trainedCost}/S{specializedCost})"); - } - private static (int Trained, int Specialized) GetCosts( IRuntimeCharacterCreationView view, uint heritageId, @@ -161,10 +268,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable return (0, 0); } - /// OnClick: one step up (Untrained/Inactive -> Trained, - /// Trained -> Specialized). Simplified from retail's separate - /// Increase/Decrease affordances (IncreaseSkillLevel/ - /// DecreaseSkillLevel) to one click target per row. + /// pSkillUpButton click: IncreaseSkillLevel + /// @0x00480ca0 — Untrained/Inactive -> Trained, + /// Trained -> Specialized. private void Advance(uint skillId) { if (_disposed) @@ -177,8 +283,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _bindings.SpecializeSkill(skillId); } - /// OnDoubleClick: one step down (Specialized -> Trained, - /// Trained -> Untrained). + /// pSkillDownButton click: DecreaseSkillLevel + /// @0x00480d60 — Specialized -> Trained, + /// Trained -> Untrained. private void Retreat(uint skillId) { if (_disposed) @@ -196,13 +303,12 @@ internal sealed class CharacterCreationSkillsPage : IDisposable if (_disposed) return; _disposed = true; - foreach (UiButton row in _rows) + foreach (SkillRow row in _rows) { - row.OnClick = null; - row.OnDoubleClick = null; + if (row.UpButton is not null) row.UpButton.OnClick = null; + if (row.DownButton is not null) row.DownButton.OnClick = null; } _rows.Clear(); - _rowSkillIds.Clear(); _list?.Flush(); if (_list is not null) _list.TemplateResolver = null; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index bf564a55..d736bf57 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -308,6 +308,41 @@ internal sealed class CharacterCreationUiController : IDisposable _appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance); _townTab.OnClick = () => ApplyProgressState(Page.Town); _summaryTab.OnClick = () => ApplyProgressState(Page.Summary); + + // GF-13 (Campaign CC gate round 1, Batch A): honor the authored + // Invisible flag (dat property 0x3B) chargen-scoped only — see + // HideAuthoredInvisibleElements's own doc comment. + HideAuthoredInvisibleElements(Root); + } + + /// + /// GF-13 (Campaign CC gate round 1, Batch A). The user's live gate + /// reported an acdream-only "-Non-admin or Non-envoy" text leak below the + /// Summary name field. Root cause: elements 0x10000403 ("Non- + /// Admin") and 0x10000494 ("Non-Envoy") author dat property + /// 0x3B (Invisible) = — retail's + /// UIElement::OnSetAttribute @0x00462d80 case 8 + /// (GetPropertyName()-0x33 == 8, property id 0x3B) hides any + /// element authoring it via SetVisible(value == 0). acdream's + /// shared never read this property at all + /// (it now does, into / + /// , a pure data addition), so + /// every one of the 1,083 elements client-wide that author it rendered + /// regardless. A blanket importer-wide honor is its own separately-gated + /// visual sweep (docs/ISSUES.md #408) — this method is the NARROW, + /// chargen-scoped fix: walk this screen's own mounted subtree once at + /// construction and hide anything the dat itself marked hidden, by the + /// AUTHORED FLAG rather than a hardcoded id list, so any other + /// authored-invisible element under this root (not just the two the user + /// happened to see) is honored the same way. Register AP-230 records the + /// scoped-vs-general split. + /// + private static void HideAuthoredInvisibleElements(UiElement element) + { + if (element.AuthoredInvisible) + element.Visible = false; + foreach (UiElement child in element.Children) + HideAuthoredInvisibleElements(child); } internal UiElement Root => _layout.Root; diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 085d5d2b..e1204f9e 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -225,6 +225,25 @@ public sealed class ElementInfo /// public uint ScrollbarElementId; + /// + /// GF-13 (Campaign CC gate round 1, Batch A): the authored Invisible flag + /// from dat property 0x3B (BoolBaseProperty). Retail + /// UIElement::OnSetAttribute @0x00462d80's case 8 + /// (BaseProperty::GetPropertyName(esi) - 0x33 == 8, i.e. property + /// id 0x33 + 8 = 0x3B): this->vtable->SetVisible(value == 0) — + /// an authored true HIDES the element at construction. Populated the + /// same way as / + /// (recomputed fresh from the effective merged state every call), but this + /// is a PURE DATA ADDITION: the shared / + /// path does not act on it. 1,083 elements + /// author this flag client-wide (docs/ISSUES.md #408, its own separately- + /// gated general-honor item) — only screens that explicitly walk their own + /// mounted subtree and check this field may hide elements by it (see + /// CharacterCreationUiController's chargen-scoped honor, register + /// AP-230). + /// + public bool Invisible; + /// /// Resolves a property for a state using retail's DirectState-as-base rule. A /// named state's key overrides DirectState by presence, including false/zero. @@ -529,6 +548,16 @@ public static class ElementReader // (DataId), UnsignedValue 100683031/100683033 == 0x06004D17/0x06004D19). info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u); info.LedUncheckedSprite = ReadReferencedElementId(info, 0x10000083u); + + // GF-13: Invisible (0x3B), BoolBaseProperty. Retail + // UIElement::OnSetAttribute @0x00462d80 case 8 — SetVisible(value == 0), + // so an authored true HIDES the element. Read via the same + // TryGetEffectiveBool the DirectState/default-state resolution rules + // already use for every other canonical-projection property above. + if (info.TryGetEffectiveBool(0x3Bu, out bool invisible)) + { + info.Invisible = invisible; + } } private static List ReadTabTable(ElementInfo info) diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 3c3f72d4..06d78aa9 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -117,6 +117,10 @@ public static class LayoutImporter var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve); if (w is null) return null; // Type-12 style prototype — skip + // GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own + // doc comment for why this does NOT set Visible here. + w.AuthoredInvisible = info.Invisible; + if (info.Id != 0) byId[info.Id] = w; // Behavioral widgets that draw their full appearance + reproduce their dat diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index f72d7283..9efc211c 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -255,11 +255,53 @@ public sealed class RetailDialogFactory : IDisposable return false; } + /// + /// GF-15 fix (Campaign CC gate round 1, Batch A, 2026-08-16). Live-repro- + /// confirmed root cause: CharacterCreationUiController.Tick and + /// CharacterManagementUiController.Tick both call + /// UiRoot.BringToFront(Root) UNCONDITIONALLY on every frame while + /// their screen is open — a per-tick "stay on top of my sibling screen" + /// assertion (needed so chargen never bleeds input to the occluded + /// char-management screen underneath it, register AP-229). A dialog this + /// factory opens is ALSO a direct sibling of those screen roots under + /// the same UiRoot (_host.AddChild(view.Root) in + /// ), competing for the SAME z-order slot. + /// is a simple "highest + /// ZOrder among _root's direct children + 1" — whichever sibling's + /// own BringToFront call runs LAST in a frame wins the top slot. + /// Before this fix, this method never re-asserted a dialog's own + /// z-order after the one-time raise in , so + /// the VERY NEXT frame's screen Tick() (which always runs before + /// this factory's own Tick() in + /// RetailUiRuntime.Tick(double)'s per-frame sequence) silently + /// buried the dialog behind the screen's opaque backdrop — while the + /// dialog remained the registered and kept + /// EXCLUSIVE input priority (OnMouseDown's Modal-vs-bounds gate is + /// independent of render/z-order). The user-visible symptom: press + /// Finish empty → the NoName dialog is created successfully + /// (visible=true, correct geometry, live-DAT-probe-confirmed) but + /// renders NOTHING, and every subsequent click across the WHOLE canvas + /// resolves to the invisible dialog root instead of the name field or + /// Finish button underneath — both GF-15 symptoms from one mechanism. + /// Retail's real dialogs are always-on-top overlays by construction (a + /// separate presentation layer, not a z-ordered sibling of the game UI); + /// re-asserting every open dialog's z-order here, every tick, in + /// order (so the MOST RECENTLY opened dialog — + /// the same one already treats as + /// authoritative — ends up on top) reproduces that invariant without + /// touching either screen controller's own already-verified raise. + /// public void Tick() { RetryFailedDialogs(); foreach (DialogInfo info in _openOrder.ToArray()) - info.View?.Tick(); + { + if (info.View is { } view) + { + _host.BringToFront(view.Root); + view.Tick(); + } + } } /// diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index cc7fcbfe..668d51fc 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -57,6 +57,19 @@ public abstract class UiElement /// Human-readable name for debugging / FindByName. public string? Name { get; init; } + /// + /// GF-13 (Campaign CC gate round 1, Batch A): mirrors + /// ElementInfo.Invisible (dat property 0x3B) — a PURE DATA + /// PASSTHROUGH set by LayoutImporter.BuildWidget at construction. + /// The shared importer does NOT act on this flag (1,083 elements author + /// it client-wide, docs/ISSUES.md #408); it exists only so a screen that + /// owns its own mounted subtree can honor it explicitly, the way + /// CharacterCreationUiController does for the chargen screen + /// (register AP-230). Reading this never changes by + /// itself. + /// + public bool AuthoredInvisible { get; internal set; } + private readonly Dictionary _stateCursors = new(); /// Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState. diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 9d450c08..7d1db271 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -617,6 +617,189 @@ public sealed class CharacterCreationLiveDatTests + "gate argument needs re-verification for this skill."); } + /// + /// GF-13 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed: + /// the GM-only labels 0x10000403 ("Non-Admin") and + /// 0x10000494 ("Non-Envoy") both live under the Summary page + /// (0x100003D6, path 0x100003CC > 0x100003D0 > + /// 0x100003D6 > {0x10000403,0x10000494}) and both author dat + /// property 0x3B (Invisible) = — the exact + /// mechanism retail's UIElement::OnSetAttribute @0x00462d80 case 8 + /// hides them by. Pins the DATA half () + /// against the installed EoR dat. + /// + [InstalledDatFact] + public void SummaryPage_NonAdminNonEnvoyLabels_AuthorInvisibleTrue() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + foreach (uint targetId in new[] { 0x10000403u, 0x10000494u }) + { + ElementInfo? found = FindInfo(rootInfo, targetId); + Assert.NotNull(found); + Assert.True( + found!.Invisible, + $"element 0x{targetId:X8} must author dat property 0x3B (Invisible) = true."); + } + } + + /// + /// GF-13: the BEHAVIOR half — after the real controller mounts through + /// (not a raw + /// call), the two authored-invisible + /// elements are not . Exercises + /// HideAuthoredInvisibleElements's real chargen-scoped honor path, + /// not just the data plumbing the sibling test above pins. + /// + [InstalledDatFact] + public void SummaryPage_NonAdminNonEnvoyLabels_HiddenAfterControllerMount() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + var host = new UiRoot(); + var dialogs = MakeDialogFactory(dats, host); + var bindings = new CharacterCreationRuntimeBindings( + () => null, + _ => default, + _ => default, + _ => default, + (_, _) => default, + (_, _) => default, + _ => default, + _ => default, + _ => default, + _ => default, + _ => default, + () => { }); + + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) => + LayoutImporter.Import( + dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root; + + CharacterCreationUiController? controller = + CharacterCreationUiController.CreateDetached( + host, screen, ResolveTemplate, dialogs, bindings, + new CharacterCreationUiController.DialogStrings( + "Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long")); + Assert.NotNull(controller); + + foreach (uint targetId in new[] { 0x10000403u, 0x10000494u }) + { + UiElement found = Assert.IsAssignableFrom( + UiElement.FindDescendant(controller!.Root, targetId)); + Assert.True(found.AuthoredInvisible, $"0x{targetId:X8} must carry AuthoredInvisible."); + Assert.False(found.Visible, $"0x{targetId:X8} must be hidden after mount."); + } + + controller!.Dispose(); + dialogs.Dispose(); + } + + /// + /// GF-5 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed + /// (raw ElementDesc.Type — the same id space as retail's + /// DynamicCast tags, 1=Button, 12=Text): the Skills listbox + /// authors exactly two templates. Templates[0] + /// (0x100002F4) is retail's own 3-child bucket-HEADER row + /// (unused by this port's flat-list simplification, AP-213). + /// Templates[1] (0x100002FF) is the REAL skill row — a + /// plain container root (rawType 3, NOT a Button), 7 children, byte- + /// traced against gmCGSkillsPage::DoSkillRecords @ 0x004817e0 + + /// tagSkillRecord's copy-constructor field order + /// (acclient.h): name (0x10000301, Text), pSkillLevelText + /// (0x10000302, Text), pUpCostText (0x10000303, Text), + /// pSkillUpButton (0x10000304, Button), pSkillDownButton + /// (0x10000305, Button), pDownCostText (0x10000306, Text). + /// See 's own class doc for the + /// full trace. + /// + [InstalledDatFact] + public void SkillsPage_RealRowTemplate_HasNameLevelCostTextAndArrowButtons() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement skillsRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.SkillsPageElementId)); + UiTemplateListBox list = Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003F7u)); + + Assert.Equal(2, list.Templates.Count); + + UiTemplateListEntry realRowTemplate = list.Templates[1]; + Assert.Equal(0x100002FFu, realRowTemplate.TemplateElementId); + + UiElement? row = LayoutImporter.Import( + dats, + realRowTemplate.TemplateLayoutId, + realRowTemplate.TemplateElementId, + _ => (0u, 0, 0), + null)?.Root; + UiElement realRow = Assert.IsAssignableFrom(row); + Assert.IsNotType(realRow); + + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000301u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000302u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000303u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000306u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000304u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000305u)); + + // Templates[0] (0x100002F4) is retail's bucket-header row — unused + // by RebuildRows, still confirmed present so a future revision that + // drops it or changes its shape shows up here. + Assert.Equal(0x100002F4u, list.Templates[0].TemplateElementId); + } + + /// + /// GF-15 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed + /// during the investigation: the Message dialog catalog's popup + /// (0x3D), message text (0x3E), and OK button + /// (0x26) all author REAL, nonzero geometry (popup 400x95, + /// centered by ) — + /// ruling out a zero-size/collapsed-layout explanation for the dialog + /// rendering nothing. The actual root cause was a Z-ORDER bug (the + /// chargen screen's own per-tick BringToFront burying the dialog + /// behind its opaque backdrop while the dialog kept exclusive + /// input priority — fixed in + /// ). This test pins the geometry + /// half so a future DAT revision that collapses the popup/message/button + /// to zero size is caught here instead of silently reintroducing an + /// invisible dialog. + /// + [InstalledDatFact] + public void MessageDialogCatalog_PopupMessageAndOkButton_AuthorNonzeroGeometry() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); + + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos(dats, dialogDid, 0x24u)); + Assert.Equal(800f, rootInfo.Width); + Assert.Equal(600f, rootInfo.Height); + + ElementInfo popup = Assert.IsType(FindInfo(rootInfo, 0x3Du)); + Assert.True(popup.Width > 0f && popup.Height > 0f); + + ElementInfo message = Assert.IsType(FindInfo(rootInfo, 0x3Eu)); + Assert.True(message.Width > 0f && message.Height > 0f); + + ElementInfo okButton = Assert.IsType(FindInfo(rootInfo, 0x26u)); + Assert.True(okButton.Width > 0f && okButton.Height > 0f); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 4dc9b0e6..462b4622 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -279,8 +279,16 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(42, environment.Runtime.LastAttributeValue); } + /// + /// GF-5 fix (2026-08-16): the row is now the REAL Templates[1] + /// (0x100002FF) subtree — a plain container root with the two + /// separate arrow buttons retail authors (pSkillUpButton + /// 0x10000304 / pSkillDownButton 0x10000305), each + /// firing on a PLAIN click (ListenToElementMessage @0x004814c0), + /// not the old single-button click-vs-double-click substitution. + /// [Fact] - public void SkillsRow_Click_TrainsThenSpecializes() + public void SkillsRow_ArrowClick_TrainsThenSpecializes() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); @@ -288,30 +296,71 @@ public sealed class CharacterCreationUiControllerTests environment.TabButton(CharacterCreationUiController.SkillsTabElementId) .OnClick!(); - // Rows are built in ascending skill-id order (RebuildRows' 1..54 - // walk over IsCostable ids) — SkillSpecializable's row is identified - // by its label prefix (FormatSkillLabel's "{name}: ..." shape) - // rather than instance identity, since the controller owns the - // row->skillId map privately. - string skillName = ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable); - UiButton row = environment.SkillsList().ViewportForTest!.Children - .OfType() - .Single(candidate => candidate.Label!.StartsWith( - skillName + ":", StringComparison.Ordinal)); + (UiButton up, UiButton down) = environment.SkillRowArrows(SkillSpecializable); - row.OnClick!(); + up.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Trained, environment.Runtime.GetSkillLevel(SkillSpecializable)); - row.OnClick!(); + up.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Specialized, environment.Runtime.GetSkillLevel(SkillSpecializable)); - row.OnDoubleClick!(); + down.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Trained, environment.Runtime.GetSkillLevel(SkillSpecializable)); } + /// + /// GF-5: the listbox produces one row per costable skill through the + /// REAL template-resolver path (Templates[1], not the bucket- + /// header Templates[0]), with name/level/cost values populated + /// from a known snapshot — the exact regression CC5's fixture tests + /// never had (they called UiField.SetText/UiButton.OnClick + /// directly, bypassing RebuildRows' own template resolution entirely). + /// + [Fact] + public void SkillsPage_Rows_RenderNameAndLevelCostValues_ThroughTheRealTemplate() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId) + .OnClick!(); + + IReadOnlyList rows = environment.SkillsList().ViewportForTest!.Children; + // Aluvian's fixture only costs SkillTrainOnly(1)/SkillSpecializable(2). + Assert.Equal(2, rows.Count); + + UiElement row = Assert.Single(rows, candidate => + UiElement.FindDescendant(candidate, 0x10000301u) is UiText name + && JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)); + + // FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10. + UiText level = Assert.IsType(UiElement.FindDescendant(row, 0x10000302u)); + Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level)); + + // Default (never-touched) level: up cost = trained cost (2), down + // cost blank (nothing below Untrained/Inactive). + UiText upCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000303u)); + UiText downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u)); + Assert.Equal("2", JoinedText(upCost)); + Assert.Equal(string.Empty, JoinedText(downCost)); + + // Advancing to Trained flips the cost pair: up = specCost-trainCost + // (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a + // lightweight stub that doesn't bump Revision itself (unlike + // production's TrySetSkillLevel, RuntimeCharacterCreationState.cs + // ~1045), so force one the same way the file's other post-click + // refresh assertions do. + environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); + RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 }; + environment.Controller.Tick(); + Assert.Equal("4", JoinedText(upCost)); + Assert.Equal("2", JoinedText(downCost)); + } + [Fact] public void TownButton_SelectsTheLiteralStartAreaIndex() { @@ -862,6 +911,94 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal("No name entered.", environment.LastDialogMessage()); } + /// + /// GF-15 (Campaign CC gate round 1, Batch A). The exact user sequence, + /// driven through the REAL / + /// event pipeline — every OTHER Summary-page + /// test in this file calls field.SetText/UiButton.OnClick!() + /// directly, which bypasses 's own pick/focus/Modal + /// dispatch entirely and is exactly why those tests kept passing while + /// the live screen was dead. Root cause (live-repro-confirmed): + /// 's own per-tick + /// UiRoot.BringToFront(Root) (needed so chargen stays above the + /// occluded character-management screen, AP-229) buried any dialog + /// opened while chargen is active on the VERY NEXT frame, because + /// never re-asserted its own open + /// dialogs' z-order — fixed by having it do so, in open-order, every + /// tick. + /// + [Fact] + public void Finish_EmptyName_RealEventPath_DialogSurvivesTheNextFrameTick_AndFieldRefocusableAfterDismiss() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + // Heritage/gender must be selected so Finish's HeritageOrGenderUnset + // local refusal (which has no retail dialog) can't preempt the + // NoName refusal this test exercises. + SelectAluvianMale(environment); + GoToSummary(environment); + + UiField nameField = environment.SummaryNameField(); + UiButton finishButton = environment.Button(CharacterCreationUiController.FinishElementId); + + // (1) A real mouse-down at the field's own screen rect sets + // KeyboardFocus to it. + Vector2 fieldPos = nameField.ScreenPosition; + environment.Host.OnMouseDown(UiMouseButton.Left, (int)fieldPos.X + 2, (int)fieldPos.Y + 2); + Assert.Same(nameField, environment.Host.KeyboardFocus); + + // (2) A subsequent OnChar lands a character in the field through + // the real event pipeline (UiRoot.OnChar -> BubbleEvent -> + // UiField.OnEvent), not a direct SetText call -- then the user's + // own live-repro clear (repeated Backspace) empties it again, so + // the field is genuinely empty when Finish commits it below + // (clicking Finish blurs the field, which commits its text -- + // CommitNameFromField -- exactly like a real click-away would). + environment.Host.OnChar('Z'); + Assert.Equal("Z", nameField.Text); + nameField.Backspace(); + Assert.Equal(string.Empty, nameField.Text); + + // (3) A real click on Finish while the field is empty. UiButton's + // own click fires on the press-release pair (OnMouseUp with the + // same target still Captured from OnMouseDown), matching every + // other real click below. + Vector2 finishPos = finishButton.ScreenPosition; + environment.Host.OnMouseDown(UiMouseButton.Left, (int)finishPos.X + 2, (int)finishPos.Y + 2); + environment.Host.OnMouseUp(UiMouseButton.Left, (int)finishPos.X + 2, (int)finishPos.Y + 2); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + Assert.True(environment.Dialogs.IsOpen); + UiPanel dialogModal = Assert.IsAssignableFrom(environment.Host.Modal); + + // (4) Reproduce the exact bug window: one full frame's worth of + // ticks in production order (CharacterCreationController.Tick then + // DialogFactory.Tick, RetailUiRuntime.Tick(double)'s own sequence). + // Before the fix, step 4a alone buried the dialog; the dialog must + // still sit at or above the screen root's z-order once step 4b (the + // fix) runs, matching retail's always-on-top dialog behavior. + environment.Controller.Tick(); + environment.Dialogs.Tick(); + Assert.True(dialogModal.ZOrder >= environment.Controller.Root.ZOrder); + + // (5) Dismiss through the real click path -- the OK button's own + // screen rect, not ConfirmActiveDialog's direct OnClick! shortcut. + UiButton okButton = Assert.IsType( + UiElement.FindDescendant(dialogModal, RetailMessageDialogView.OkButtonId)); + Vector2 okPos = okButton.ScreenPosition; + environment.Host.OnMouseDown(UiMouseButton.Left, (int)okPos.X + 2, (int)okPos.Y + 2); + environment.Host.OnMouseUp(UiMouseButton.Left, (int)okPos.X + 2, (int)okPos.Y + 2); + Assert.False(environment.Dialogs.IsOpen); + Assert.Null(environment.Host.Modal); + + // (6) The user's own final check: the field is still typable + // afterward, through the same real click+char path. + environment.Host.OnMouseDown(UiMouseButton.Left, (int)fieldPos.X + 2, (int)fieldPos.Y + 2); + Assert.Same(nameField, environment.Host.KeyboardFocus); + environment.Host.OnChar('Q'); + Assert.Contains('Q', nameField.Text); + } + [Fact] public void Finish_UnspentCredits_ShowsCreditWarning_ConfirmResendsWithConfirmedFlag() { @@ -1345,6 +1482,22 @@ public sealed class CharacterCreationUiControllerTests public UiTemplateListBox SkillsList() => Assert.IsType(Screen.FindElement(0x100003F7u)); + /// GF-5: locates a built skill row by its name text + /// (0x10000301) and returns its up (0x10000304, + /// pSkillUpButton) / down (0x10000305, + /// pSkillDownButton) arrow buttons. + public (UiButton Up, UiButton Down) SkillRowArrows(uint skillId) + { + string skillName = ItemAppraisalTextFormatter.SkillName((int)skillId); + UiElement row = Assert.Single( + SkillsList().ViewportForTest!.Children, + candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name + && JoinedText(name) == skillName); + UiButton up = Assert.IsType(UiElement.FindDescendant(row, 0x10000304u)); + UiButton down = Assert.IsType(UiElement.FindDescendant(row, 0x10000305u)); + return (up, down); + } + public UiTemplateListBox SummaryListBox() => Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId)); @@ -1829,7 +1982,14 @@ public sealed class CharacterCreationUiControllerTests root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId)); - root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId)); + // GF-15 fix round: FinishElementId needs a real, non-default + // position for the real-event-path regression test — see the + // matching comment on the Summary name field below. Clear of both + // the listbox (20,40)-(420,340) and the field (450,100)-(490,116). + ElementInfo finishInfo = ButtonInfo(CharacterCreationUiController.FinishElementId); + finishInfo.X = 600f; + finishInfo.Y = 500f; + root.Children.Add(finishInfo); root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId)); @@ -1911,7 +2071,13 @@ public sealed class CharacterCreationUiControllerTests Width = 300f, Height = 320f, }; - list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100003FEu)); + // GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row + // (0x100002F4, unused by this port's flat-list simplification); + // [1] (0x100002FF) is the REAL skill row RebuildRows now resolves — + // see CharacterCreationSkillsPage's own class doc for the byte + // trace pinning both ids and their child shapes. + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u)); + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu)); page.Children.Add(list); page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge page.Children.Add(TextInfo(0x100003FBu)); @@ -2020,8 +2186,38 @@ public sealed class CharacterCreationUiControllerTests return spin; } - private static UiElement BuildSkillRowTemplate(uint templateElementId) => - LayoutImporter.Build( + /// + /// GF-5: mirrors Templates[1]'s real installed-DAT shape + /// (0x100002FF, live-DAT-probe-confirmed against + /// CharacterCreationSkillsPage's own class doc) — a plain + /// container root, NOT a UiButton, with the six children + /// RebuildRows/RefreshRowValues resolve by id. Template + /// 0x100002F4 (retail's unused bucket-header row) falls back to + /// a bare button shape since this port's flat-list simplification never + /// resolves it. + /// + private static UiElement BuildSkillRowTemplate(uint templateElementId) + { + if (templateElementId == 0x100002FFu) + { + var row = new ElementInfo + { + Id = templateElementId, + Type = 3u, + Width = 280f, + Height = 16f, + }; + row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop + row.Children.Add(TextInfo(0x10000301u)); // name + row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText + row.Children.Add(TextInfo(0x10000303u)); // pUpCostText + row.Children.Add(ButtonInfo(0x10000304u)); // pSkillUpButton + row.Children.Add(ButtonInfo(0x10000305u)); // pSkillDownButton + row.Children.Add(TextInfo(0x10000306u)); // pDownCostText + return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root; + } + + return LayoutImporter.Build( new ElementInfo { Id = templateElementId, @@ -2031,6 +2227,7 @@ public sealed class CharacterCreationUiControllerTests }, _ => (0u, 0, 0), null).Root; + } // ── Summary page fixture (CC5) ─────────────────────────────────────── // Template element ids match the LIVE-DAT-probe-confirmed retail ones @@ -2066,7 +2263,21 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(list); page.Children.Add(ScrollbarInfo(CharacterCreationSummaryPage.ScrollId)); - page.Children.Add(EditableFieldInfo(CharacterCreationSummaryPage.NameTextId)); + + // GF-15 fix round (2026-08-16): the real-event-path regression test + // (Finish_EmptyName_RealEventPath_...) drives UiRoot.OnMouseDown by + // actual screen coordinates, unlike every other test in this file. + // EditableFieldInfo's own default X/Y (0,0) would collide with the + // tab strip's own default (0,0) position (BuildScreen's tab buttons + // are never given explicit coordinates either — no prior test + // needed them, since they all click via .OnClick!() directly) — + // clear of both the listbox (20,40)-(420,340) and the default- + // positioned tab/nav buttons at Y=0. + ElementInfo nameFieldInfo = EditableFieldInfo(CharacterCreationSummaryPage.NameTextId); + nameFieldInfo.X = 450f; + nameFieldInfo.Y = 100f; + page.Children.Add(nameFieldInfo); + page.Children.Add(TextInfo(CharacterCreationSummaryPage.HowToTextId)); var viewport = new ElementInfo