From d2a71152d23dbdd0cf31d6566b3a1e2b7820d35d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 22:19:02 +0200 Subject: [PATCH] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC6b-MOUNT=20re?= =?UTF-8?q?view=20fix=20round=20=E2=80=94=20F1-F13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes every finding from the dual-lens review of 34c6fceab0 (architectural PASS-with-items, retail-fidelity FAIL). Re-derived every decomp citation against docs/research/named-retail/acclient_2013_pseudo_c.txt directly rather than trusting the reviewer's transcription. Wrap/normalize semantics (F1): CycleIndex's decrement-from-Unset landed on 0; the decomp's shared decrement tail (label_47f065/label_47f6d9, the same switch the headgear ring was ported from) computes new=cur-1=-2 on the raw signed int32, which wraps to count-1 — matching headgear's own ring shape. Also ports the spin body-click normalize-and-write-back retail's cases 0xa5-0xae all share (NormalizeChoiceOnSelect), which acdream had dropped entirely. Flips the one test that pinned the wrong expectation and adds select-zone coverage no prior test isolated. Heritage gate (F3): Update's Gearknight/Olthoi/OlthoiAcid branches reset SetChoice(FACE)/SetSelection(HAIR) unconditionally, not only when Clothes was showing — a conditional gate stranded Nose/Mouth as the current part under a Face-tab session. Doc corrections propagated everywhere they repeated (F4, F5, F6, plus the plan doc's own CC6b-MOUNT ledger row for F1/F3): the gmBarberUI heading citation conflated PostInit with InitializePage; Random's Appearance disable was mislabeled a placeholder when it's really AP-212's unported RandomizeAppearance/RandomizeClothing gap; the master-page doc still called the Appearance page content-inert after this campaign made it real. Visual substitutions widened (F2): AP-215 named only two of the Appearance page's swatch/spin substitutions. Ports the two cheap ones directly — current-part highlight via SetSelection's SetState(1)/SetState(6), routed through the existing UiButtonStateMachine.Normal/Highlight ids and IUiDatStateful.TrySetRetailState seam (installed-DAT-confirmed ToggleBehavior=true on all nine spins); the shade scrollbar's SetVisible(0) for Eyes vs acdream's Enabled=false. Files the other five (DoColorSpots, the inert GradCircle, spin-caption/heritage-caption loss, the Skin-spin MoveTo reposition, the Gearknight-boundary randomize calls) as new register rows AP-216..AP-220 and corrects the plan doc's false claim that AP-215 already named the GradCircle. Unlocked DAT read (F7, BLOCKER): ChargenPreviewController.Rebuild called ChargenAppearanceFactory.TryCompose outside _datLock while the very next line correctly locked TryBuildAnimated — CC6a's own F4 class of bug, reintroduced at this catalog's first production call site. Wrapped in the same lock; documented the invariant on ChargenAppearanceCatalog itself. One-shot preview mount (F8): LivePresentationComposition reads ChargenPreviewViewportWidget once, but its underlying mount (CharacterCreationUiMountCoordinator) is explicitly retryable while this GPU-resource composition pass is not — unlike PaperdollViewportWidget, which IS eager/non-retryable, so the "mirrors Paperdoll" doc claim was false. Retrofitting cross-frame retry here would mean restructuring this composition's one-shot contract for every private viewport (paperdoll, creature appraisal) and FrameRootComposition's fixed frame-group array — out of this round's blast radius. Corrected the doc and made the failure loud (a diagnostic log) instead of silent. Dispose leak (F9): ChargenPreviewController.Dispose left the preview WorldEntity referenced by the leased renderer until the renderer's own, later disposal. Releases it on its own teardown now. Test-quality items (F10, F11, F13): pinned the spin arrow widths (47px, both arrows) the 174 zone boundary is derived from, plus a controller test for the previously-uncovered select zone. Measured the shade scrollbar's authored orientation instead of assuming it — it is VERTICAL (33x85) — which is a real production bug: UiScrollbar only routed scalar-mode mouse events when Horizontal was true, so the shade control never fired in production. Added OnVerticalScalarEvent/DrawVerticalScalar mirroring the existing horizontal scalar path. Converted ChargenPreviewControllerTests from silent-pass [Fact] to the shared InstalledDatFactAttribute skip-reporting pattern. Adjudication (F12): AD-101's retirement leaves TryBeginFinish's four local refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) with no heritage/gender gate — currently latent since Finish stays hard-disabled this round. Amended the campaign plan's CC5 slice scope to require BOTH a heritage/gender refusal AND a real RandomizeCharacter port before the connected user gate opens Finish; noted the interaction on AP-214's own register row. No CC5 implementation in this commit. Gates: dotnet build -c Release green across the full solution. App suite (Release, ACDREAM_PROBE_LIVE_MOUNT=1) 5223/3 skips, Runtime suite 1713/0 — both clean across repeated runs. A full-solution run surfaced three pre-existing, previously-documented flakes unrelated to this change (Streaming.LandblockBuildFactoryTests/LandblockPresentationPipelineTests #402, Core.Net.Tests.NakEmissionTests loss soak) — each confirmed passing in isolation, consistent with their known full-suite-parallelism-timing history; none touch any file this commit changes. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 11 +- .../2026-08-15-character-creation-campaign.md | 4 +- .../LivePresentationComposition.cs | 37 ++++ .../Rendering/ChargenPreviewController.cs | 27 ++- .../ChargenPreviewRotationController.cs | 16 +- .../Layout/CharacterCreationAppearancePage.cs | 164 ++++++++++++++++-- .../Layout/CharacterCreationUiController.cs | 20 ++- src/AcDream.App/UI/RetailUiRuntime.cs | 26 ++- src/AcDream.App/UI/UiScrollbar.cs | 114 +++++++++++- .../CharGen/ChargenAppearanceCatalog.cs | 15 ++ .../ChargenPreviewControllerTests.cs | 33 +++- .../Layout/CharacterCreationLiveDatTests.cs | 37 +++- .../CharacterCreationUiControllerTests.cs | 84 ++++++++- .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 37 ++++ 14 files changed, 571 insertions(+), 54 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 20985013..940fff66 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) — 151 active rows (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) — 156 active rows (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 @@ -390,9 +390,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | | AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions).** Two narrow, DECIDED substitutions where acdream reaches the same functional selection through a different widget mechanism than retail's own: (1) the nine color swatches (`0x1000030f-0x10000317`) use their own `UiButton.Selected` highlight state for "this is the current color" instead of toggling the separate Type-3 companion overlay element (`0x10000318-0x10000320`) retail's `SetColor @ 0x0047DD50` shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — the composited pixel result is UNVERIFIED to match, not asserted identical (same "measured, not assumed" discipline AD-103's own F5 note established for a different swallowed-child case). (2) the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons is out of this round's scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop; `SetStyleSpinLabel`) | Both substitutions reach the SAME underlying selection (the swatch highlight still shows which color index is active; the ordinal still lets a player cycle deterministically and see which slot they're on) through existing widget primitives (`UiButton.Selected`, `UiButton.Label`) rather than adding new rendering infrastructure (a second overlay-visibility channel, or an icon-texture pipeline) this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a different (simpler) selected-swatch visual and text labels where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close the label half of this row. | `gmCGAppearancePage::SetColor @0x0047DD50` (the `m_tColorWheel` overlay toggle); `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | -| AP-214 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (AD-101's retirement research).** Retail's chargen screen does NOT open blank: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0` calls `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80` (~`0x004e81f5`-`0x004e8218`) BEFORE constructing any page (Heritage/Profession/Skills/Appearance/Town/Summary all `InitializePage` AFTER this call) — `RandomizeCharacter` itself Resets then rolls a random heritage (`RollDice(1, hasToD?4:3)`), a random gender (`RollDice(1,2)`), `RandomizeAppearance`, `RandomizeHeadgear`/`Shirt`/`Trousers`/`Footwear`, `RandomizeTemplate`, and `RandomizeStartArea`, freezing heritage/sex/appearance. This ALSO resolves the plan's risk item 5 "gender-flip-on-init oddity" at `gmCGAppearancePage::InitializePage @0x0047FDD0` (~`0x004802DA`-`0x00480303`): since `RandomizeCharacter` already assigned a real (non-zero) gender before the Appearance page constructs, that page's own gender-read-and-FLIP-to-the-opposite code ALWAYS fires on first open, deterministically inverting `RandomizeCharacter`'s random gender pick — a genuine, always-reachable retail quirk, not a latent/unreachable one. acdream does not port `RandomizeCharacter` this round — the same six missing Runtime primitives (`RandomizeHeritageGroup`/`RandomizeGender`-via-`SetGender`/`RandomizeAppearance`/`RandomizeClothing`(via the four Randomize* gear calls)/`RandomizeTemplate`/`RandomizeStartArea`) AP-212 already tracks for the Random BUTTON are the SAME gap that would be needed here — so acdream's chargen screen opens honestly blank (heritage/gender/appearance all `Unset`) and the player makes every choice explicitly, including gender on the Appearance page (AD-101's retirement). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (no `RandomizeCharacter`-equivalent call at construction — the gap itself); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`, AD-101's retirement point) | Full-fidelity would require porting `RandomizeCharacter` and its six sub-primitives into Runtime (AP-212's own "known landing site" note) — out of this slice's scope, which is the Appearance page's own controls, not a fourth cut at the Random button's primitives. Landing this WOULD ALSO close AP-212's gap for the "Random button while on Summary" case, since retail's `DoRandom`'s own Summary branch is a direct `RandomizeCharacter` call. | A connected two-client visual gate comparing "what does the chargen preview show on first open" against retail would see a blank/default acdream character versus retail's fully-randomized one — an expected, documented divergence, not a bug; the FLIP quirk itself has zero acdream analogue to diverge from (there's nothing to flip when gender starts Unset). | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`); `CharGenState::RandomizeCharacter @0x005c6d80`; `CharGenState::Reset @0x005c68a0` (confirms `SetGender(this,0)` is the ONLY other gender-touching call in the reset path); `gmCGAppearancePage::InitializePage @0x0047FDD0` (`~0x004802da-0x00480303`, the gender-flip arm) | +| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1).** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. acdream's swatches show only their authored (static) DAT art regardless of which color they represent or whether the current part even has that many colors — AP-215's `.Selected` substitution covers WHICH swatch is chosen, not what each swatch itself looks like. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — sets `.Selected` only, never touches swatch appearance) | The nine swatches already reach the correct SELECTION semantics through `DatWidgetFactory`'s existing `UiButton` primitive; painting each swatch with a computed color needs either a per-swatch dynamic-color render path (new UI infrastructure this scope doesn't otherwise need) or a fallback to static art, which is what this round shipped. | A side-by-side against retail shows every swatch drawing the SAME authored art regardless of which color it represents, and swatches beyond a part's real color count staying visibly "on" instead of blanking — a real visual gap on a screen the player stares at while picking a color, not a selection-correctness gap. | `gmCGAppearancePage::DoColorSpots @0x0047d850` | +| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4) — corrects the campaign plan's own CC6b-MOUNT ledger row, which wrongly claimed AP-215 already named the GradCircle.** Retail's `gmCGAppearancePage::DoGradDisk @0x0047da90` drives the GradCircle (`0x1000030e`) as an interactive hue/gradient picker, click-mapped to a color. acdream imports the GradCircle through the generic Type-3 `UiDatElement` fallback (the risk-item-4 color-wheel scouting result) with no click handling wired to it at all — it is purely decorative in this round. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`GradCircleId` is resolved by the live-DAT test only; the page's own constructor never binds a handler to it) | The nine swatch buttons already provide a full, decomp-cited color-selection path (`SetColor`'s own cases `5`-`0xd`); the GradCircle's own click-to-color-position mapping has no decomp citation yet in this campaign's research. | A user clicking the GradCircle in acdream gets no response at all, where retail would change the current part's color — a dead-control gap a visual gate would surface immediately. | `gmCGAppearancePage::DoGradDisk @0x0047da90` | +| AP-218 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 5).** Retail's `gmCGAppearancePage::Update` sets the Hair/Eyes/Skin spins' text to a heritage-flavored STATIC caption via `UIElement_Text::SetStringInfoWithFont` — normal heritage: `ID_CharGen_HairStyle`/`ID_CharGen_Eyes`/`ID_CharGen_Skin`; Olthoi/OlthoiAcid: `ID_CharGen_OlthoiText_HairButton`/`_EyesButton`/`_SkinButton`; Gearknight: `ID_CharGen_GearText_HairButton`/`_EyesButton`/`_SkinButton`. acdream's `SetStyleSpinLabel` instead overwrites the SAME label slot with a raw 1-based ordinal (or `"-"` when Unset) on all four icon-only spins (Hair/Eyes/Nose/Mouth) — neither the caption text nor its heritage-specific swap survives, and the ordinal itself is already a scope-cut stand-in for retail's icon thumbnail (CC1/AP-215). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`SetStyleSpinLabel`) | The icon-rendering gap (CC1/AP-215) already means the spin can't show retail's icon thumbnail either way this round; reusing the SAME `.Label` slot for a numeric position indicator gives the player SOME feedback about which style is selected without adding a second text element this round's widget catalog doesn't otherwise carry. | A side-by-side against retail shows a numbered ordinal where retail shows static caption text (heritage-flavored) with an icon for the value — a cosmetic/informational gap, not a selection-correctness gap; a Gearknight or Olthoi player sees the SAME generic ordinal a normal-heritage player would, losing the heritage-specific caption entirely. | `gmCGAppearancePage::Update` caption writes @0x0047ebad (`ID_CharGen_HairStyle`), @0x0047ebe3 (`ID_CharGen_Eyes`), @0x0047ec6a (`ID_CharGen_Skin`); @0x0047ed5b/@0x0047ed91/@0x0047ee15 (Olthoi `OlthoiText_*` variants); @0x0047e9ef/@0x0047ea25/@0x0047eaa9 (Gearknight `GearText_*` variants) | +| AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | +| AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7).** 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 something else (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6`). 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-214 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (AD-101's retirement research).** Retail's chargen screen does NOT open blank: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0` calls `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80` (~`0x004e81f5`-`0x004e8218`) BEFORE constructing any page (Heritage/Profession/Skills/Appearance/Town/Summary all `InitializePage` AFTER this call) — `RandomizeCharacter` itself Resets then rolls a random heritage (`RollDice(1, hasToD?4:3)`), a random gender (`RollDice(1,2)`), `RandomizeAppearance`, `RandomizeHeadgear`/`Shirt`/`Trousers`/`Footwear`, `RandomizeTemplate`, and `RandomizeStartArea`, freezing heritage/sex/appearance. This ALSO resolves the plan's risk item 5 "gender-flip-on-init oddity" at `gmCGAppearancePage::InitializePage @0x0047FDD0` (~`0x004802DA`-`0x00480303`): since `RandomizeCharacter` already assigned a real (non-zero) gender before the Appearance page constructs, that page's own gender-read-and-FLIP-to-the-opposite code ALWAYS fires on first open, deterministically inverting `RandomizeCharacter`'s random gender pick — a genuine, always-reachable retail quirk, not a latent/unreachable one. acdream does not port `RandomizeCharacter` this round — the same six missing Runtime primitives (`RandomizeHeritageGroup`/`RandomizeGender`-via-`SetGender`/`RandomizeAppearance`/`RandomizeClothing`(via the four Randomize* gear calls)/`RandomizeTemplate`/`RandomizeStartArea`) AP-212 already tracks for the Random BUTTON are the SAME gap that would be needed here — so acdream's chargen screen opens honestly blank (heritage/gender/appearance all `Unset`) and the player makes every choice explicitly, including gender on the Appearance page (AD-101's retirement). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (no `RandomizeCharacter`-equivalent call at construction — the gap itself); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`, AD-101's retirement point) | Full-fidelity would require porting `RandomizeCharacter` and its six sub-primitives into Runtime (AP-212's own "known landing site" note) — out of this slice's scope, which is the Appearance page's own controls, not a fourth cut at the Random button's primitives. Landing this WOULD ALSO close AP-212's gap for the "Random button while on Summary" case, since retail's `DoRandom`'s own Summary branch is a direct `RandomizeCharacter` call. | A connected two-client visual gate comparing "what does the chargen preview show on first open" against retail would see a blank/default acdream character versus retail's fully-randomized one — an expected, documented divergence, not a bug; the FLIP quirk itself has zero acdream analogue to diverge from (there's nothing to flip when gender starts Unset). **Latent Finish-path interaction noted at the CC6b-MOUNT review fix round (F12, 2026-08-15):** with AD-101 retired, honest-blank heritage/gender means `RuntimeCharacterCreationState.TryBeginFinish` can be reached with `_genderKey == 0` (or an unselected heritage) — `TryBeginFinish`'s four refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) have no heritage/gender gate today. Currently LATENT ONLY (Finish is hard-disabled + `OnClick` null this round — TS-82); CC5's own scope is now AMENDED (see the plan doc's Slices table) to land BOTH a heritage/gender refusal in `TryBeginFinish` AND a real `RandomizeCharacter` port before the connected user gate opens Finish for real use, since a gate alone does not reproduce retail's actual guarantee (retail's ctor-time `RandomizeCharacter` means heritage/gender are NEVER unset by the time a player can reach Finish at all). | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`); `CharGenState::RandomizeCharacter @0x005c6d80`; `CharGenState::Reset @0x005c68a0` (confirms `SetGender(this,0)` is the ONLY other gender-touching call in the reset path); `gmCGAppearancePage::InitializePage @0x0047FDD0` (`~0x004802da-0x00480303`, the gender-flip arm) | | 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-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).** `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` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (this round's placeholder), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `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`) | 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 without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | +| 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).** `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` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (CC6b-MOUNT review fix F5 correction: NOT a placeholder — retail's own `DoRandom` case 3 fully enables Random here; the disable rests on the same unported `RandomizeAppearance`/`RandomizeClothing` primitives this row already names), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `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`) | 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 without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `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, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | ## 4. Temporary stopgap (TS) — 50 active rows (TS-82 filed 2026-08-15 at Campaign CC slice CC4 — the Appearance/Summary page roots mount empty and content-inert, reachable via free tab navigation, pending CC5/CC6a/CC6b; TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 5352207f..f7fa2011 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -165,7 +165,7 @@ human-form camera offsets). | CC2 | Wire: `CharacterCreate` 0xF656 builder (byte-exact incl. checksum), shared verification-response type (refactor from `CharacterRestore`), WorldSession request-correlation for 0xF643, send seam, status events + contract/tailer update | — | | CC3 | `RuntimeCharacterCreationState`: full CharGenState mirror, per-page commands, retail client gates (full-spend, name, 55-slot invariant, client-side slot cap), verification latch, Ok → roster append + retail log-straight-in | CC1, CC2 | | CC4 | Screen shell + form pages (App): mount (enum 0x10000039), master nav/tabs/progress, dialogs, Heritage + Profession + Skills + Town pages | CC1, CC3 | -| CC5 | Summary page: name input (NameInputFilter, `ID_CharGen_NameTooLong`), summary listbox, static summary viewport, Finish gates + full response/dialog handling | CC3, CC4 | +| CC5 | Summary page: name input (NameInputFilter, `ID_CharGen_NameTooLong`), summary listbox, static summary viewport, Finish gates + full response/dialog handling. **CC6b-MOUNT review fix round F12 amendment (2026-08-15):** Finish gates MUST add a heritage/gender refusal to `RuntimeCharacterCreationState.TryBeginFinish` — with AD-101 retired, a caller can hold `_genderKey == 0` (or, before a real heritage/gender selection, `_heritageId == 0`) all the way to Finish, and `TryBeginFinish`'s current four refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) have no gate for either — see AP-214's own noted latent-interaction risk. This slice MUST ALSO land a real `RandomizeCharacter` port (the shared AP-214/AP-212 primitive gap) BEFORE the connected user gate opens Finish for real use — the reviewer's requirement, not optional polish: retail's `gmCharGenMainUI` ctor rolls a full character before any page constructs (AP-214), so a heritage/gender check alone does not reproduce retail's actual guarantee that Finish is never reachable with an unset heritage/gender; only porting `RandomizeCharacter` closes that gap the way retail's own architecture does. | CC3, CC4 | | CC6 | Appearance page + preview: index→ObjDesc factory, chargen preview renderer (offscreen, heading camera, rotate/zoom buttons), spin controls + color wheels; **staged:** CC6a static-pose preview (paperdoll-style held frame, register row for the missing idle loop), CC6b idle animation + zoom rest-freeze (retire the row) | CC1, CC4 | | CC7 | End-to-end: Create button un-ghosts, full flow vs ACE shapes in tests, launcher payload cycle, connected checklist doc | all | @@ -258,4 +258,4 @@ the user gate. **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | — | | | | -| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice) | (this commit) | OWED (dual-lens review pending — Sonnet-implementation session only) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. Non-headgear spins have no decomp-observable Unset-starting-point case (retail always has a real index by the time the user can click — see AP-214) so a first click from Unset in EITHER direction starts at style 0 (a documented, non-retail-cited edge-case default, not a guess dressed as a citation). **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); forces `SetChoice(FACE)` if Clothes was showing when the gate engages. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins and the GradCircle's own interactive click-to-hue behavior (AP-215 both name this — the GradCircle is currently a non-interactive static container this round, since its own click-to-color-position mapping has no decomp citation yet and the nine swatch buttons already provide a full, decomp-cited color-selection path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it. | +| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, (this commit — the review fix round) | fix round landed F1-F13, narrow re-review pending | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own interactive click-to-hue behavior (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217 — the GradCircle is currently a non-interactive static container this round, since its own click-to-color-position mapping has no decomp citation yet and the nine swatch buttons already provide a full, decomp-cited color-selection path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it. | diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 92f2df7b..c9c4d8db 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -997,6 +997,31 @@ internal sealed class LivePresentationCompositionPhase // Same "both arms exist, needs a dispatcher + the retained-UI // viewport widget" shape as paperdoll/creature-appraisal above — // this is the THIRD private creature viewport, not a new pattern. + // + // Fix round F8 disposition: unlike paperdoll's PaperdollViewportWidget + // (an eager, non-retryable auto-property — see that property's own + // corrected doc comment), ChargenPreviewViewportWidget is + // computed-through a coordinator (CharacterCreationUiMountCoordinator) + // that IS explicitly retryable/idempotent across frames. This + // composition pass itself runs EXACTLY ONCE, synchronously, inside + // GameWindow.OnLoad — if the coordinator's mount hasn't succeeded + // yet at this exact instant, this block is skipped and NEVER + // retried; the coordinator's own later per-frame retries (driven + // from RetailUiRuntime.Tick) can still complete the CONTROLLER mount + // afterward, but this GPU-side renderer/viewport binding will not + // pick that up. DECIDED at the review: this composition pass is a + // one-shot GPU-resource wiring step (matching paperdoll's and + // creature-appraisal's own one-shot binding in this exact method, + // and PublishLivePresentation's own "set exactly once" invariant a + // few hundred lines below) — retrofitting cross-frame retry here + // would mean restructuring this whole composition's one-shot + // contract (and the fixed PrivateEntityViewportFrameGroup array + // FrameRootComposition builds from its result) for every private + // viewport, not just this one; that is out of this fix round's + // blast radius. What changes here instead: a loud diagnostic + // instead of a silent skip, so an operator can SEE the preview + // failed to bind this session rather than the symptom (dead + // zoom/rotate buttons) reading as unexplained. CompositionAcquisitionScope.CompositionAcquisitionLease< ChargenPreviewRenderer>? chargenPreviewLease = null; ChargenPreviewController? chargenPreviewController = null; @@ -1053,6 +1078,18 @@ internal sealed class LivePresentationCompositionPhase } }); } + else if (dispatcherLease.Resource is not null) + { + // Fix round F8: dispatcher is available but the mount coordinator + // hadn't resolved ChargenPreviewViewportWidget by this one-shot + // pass — loud instead of silent, since the coordinator's own + // later per-frame retries cannot recover this GPU-side binding + // (see this block's own disposition comment above). + Console.WriteLine( + "[UI] chargen preview viewport unavailable at composition " + + "time — the Appearance page's zoom/rotate controls and " + + "3D preview will not function this session."); + } Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs index 91c1e669..94998b47 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -225,9 +225,24 @@ internal sealed class ChargenPreviewController : return true; } - if (!ChargenAppearanceFactory.TryCompose( + // Fix round F7 (BLOCKER, CC6a's own F4 re-introduced at a new site): + // TryCompose reaches ChargenAppearanceCatalog.TryGetPalSet/ + // TryGetClothingTable (_palSets/_clothingTables), which do lazy raw + // DatCollection.Get() reads on first use — DatCollection is NOT + // thread-safe (feedback_phase_a1_hotfix_saga.md), and this UI-thread + // Rebuild call is the catalog's first production call site. Every + // sibling DAT read in this same method already guards with + // _datLock (see the TryBuildAnimated call just below) — this one + // must too. + bool composed; + ChargenAppearanceResult result; + lock (_datLock) + { + composed = ChargenAppearanceFactory.TryCompose( options, heritageId, genderKey, selection, - _palSets, _clothingTables, out ChargenAppearanceResult result)) + _palSets, _clothingTables, out result); + } + if (!composed) { return false; } @@ -291,6 +306,14 @@ internal sealed class ChargenPreviewController : if (_disposed) return; _disposed = true; + // Fix round F9: release the preview entity NOW rather than leaving + // the leased renderer holding it until the renderer's OWN disposal + // (a separate manifest entry, one step later) — this class built + // the entity via Rebuild, so it releases it on its own teardown + // instead of relying on a downstream owner to notice. + _renderer.SetPreview(null); + _animator = null; + _zoom = null; // The renderer itself is a leased composition resource disposed by // the composition root (mirrors PaperdollViewportRenderer — this // class does not own its lifetime, only its per-frame drive). diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs index 265c5da4..3e7fa9e9 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -59,10 +59,18 @@ internal sealed class ChargenPreviewRotationController /// user actually sees. The same override, independently, is what every /// other gmCG3DView owner does for ITS own instance: /// gmCGSummaryPage::InitializePage @0x0047BD54 (a separate - /// viewport/page, CC5's scope, not this one) and - /// gmBarberUI::PostInit (~0x004DE330, pushed at - /// 0x004E03B5) both call the identical - /// SetPlayerHeading(m_p3DView, 180f) for their own pages. Since + /// viewport/page, CC5's scope, not this one) and gmBarberUI + /// corroborate 180 TWICE, in two separate functions (fix round F4 + /// correction — the original citation here wrongly attributed both + /// writes to PostInit): gmBarberUI::PostInit @0x004de2e0 + /// has its OWN m_fCurHeading = 180f write at 0x004de330 + /// (no push there — PostInit ends right after that assignment); + /// separately, gmBarberUI::InitializePage @0x004e0040 has its OWN + /// redundant m_fCurHeading = 180f write at 0x004e03ab, + /// THEN pushes it via SetPlayerHeading(m_p3DView, 180f) at + /// 0x004e03b5 — the address the original citation attributed to + /// PostInit. Two functions, both landing on 180, not one + /// function pushing from the other's write. Since /// this controller — like retail's m_fCurHeading — is itself the /// PAGE-level heading owner (not the view's), matching the value every /// real page converges on before its first frame is the retail-faithful diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index 82b4fe60..ec3907be 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -257,10 +257,19 @@ internal sealed class CharacterCreationAppearancePage : IDisposable if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin)) mouthSpin.Visible = !clothesHidden; _eyesArrowsDisabled = clothesHidden; - if (clothesHidden && _currentChoice == Choice.Clothes) + if (clothesHidden) { - // Update forces SetChoice(ECG_CHOICE_FACE) when Clothes becomes - // unreachable so the page never gets stuck showing a hidden tab. + // Fix round F3: retail's Gearknight branch + // (@0x0047eac6/0x0047eacf) and Olthoi/OlthoiAcid branch + // (@0x0047ee32/0x0047ee3b) both call SetChoice(ECG_CHOICE_FACE) + // + SetSelection(ECG_PARTS_HAIR) UNCONDITIONALLY — every single + // time Update runs while the heritage hides Clothes, not only + // when the Clothes tab happened to be showing. A conditional + // gate here (checking _currentChoice == Choice.Clothes) missed + // the case where _currentPart was Nose or Mouth — both ALSO + // hidden by this same branch — while _currentChoice was still + // Face: acdream would leave the hidden Nose/Mouth part driving + // the shade control; retail always snaps back to Hair. _currentChoice = Choice.Face; _currentPart = Part.Hair; } @@ -334,10 +343,87 @@ internal sealed class CharacterCreationAppearancePage : IDisposable { if (_disposed) return; + NormalizeChoiceOnSelect(part); _currentPart = part; RefreshColorAndShadeControlsFromLatestSnapshot(); } + /// + /// Fix round F1: ports retail's spin BODY-click normalize-and-write-back + /// — gmCGAppearancePage::ListenToElementMessage cases 0xa5- + /// 0xa9 (hair/eyes/nose/mouth/skin, @0x0047f04b-0x0047f1bf) + /// and 0xab-0xae (headgear/shirt/trousers/footwear, + /// @0x0047f212-0x0047f3ac) each re-clamp the part's current index + /// into [0, count) BEFORE selecting it as current, not just read + /// it. Retail's rule (Hair's case 0xa5 is representative, + /// @0x0047f051-0x0047f081 plus the shared tail at + /// label_47f065/label_47f6d9): cur >= count -> 0; + /// cur < 0 -> count-1. Headgear's own case (0xab, + /// @0x0047f218-0x0047f23e) excludes its 0xffffffff Unset + /// sentinel from the "cur < 0" branch + /// (iCurrentChoice < 0 && iCurrentChoice != 0xffffffff), + /// so an Unset headgear survives a body click untouched; every other + /// indexed spin has no such exclusion, so an Unset (AP-214 honest-blank) + /// style wraps to count-1 on the FIRST body click — the same + /// count-1 wrap 's own decrement-from-Unset fix + /// (F1's sibling finding) applies. Skin (case 0xa9, + /// @0x0047f1bf-0x0047f1fb) normalizes its local cache too but + /// never writes back (no CharGenState field for Skin — acdream: + /// no case), matching this method's no-op + /// early return for it. In acdream there is no separate UI-local cache + /// to desync from the persisted index (unlike retail's m_tChoices) + /// — + /// already rejects any out-of-range write and + /// ConstrainAppearanceByGenderLocked already clamps on every + /// gender change — so the ONLY reachable out-of-range case here is + /// Unset itself; the >=count branch is kept for completeness/fidelity + /// with retail's own defensive shape, not because acdream can hit it. + /// + private void NormalizeChoiceOnSelect(Part part) + { + ChargenAppearanceSlot? slot = StyleSlotFor(part); + if (slot is null) + return; // Skin: retail normalizes locally but never writes back. + + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + + int count = StyleCount(part, gender); + if (count <= 0) + return; + uint current = StyleCurrent(part, snapshot.Appearance); + + uint normalized; + if (part == Part.Headgear) + { + // 0x0047f218/0x0047f226: cur >= count -> Unset; Unset itself + // (cur < 0 as signed int32) is explicitly excluded from the + // "cur < 0 -> count-1" branch, so it stays Unset. + if (current != Unset && current >= (uint)count) + normalized = Unset; + else + return; + } + else + { + // 0x0047f04b family: cur >= count -> 0; cur < 0 -> count-1. + // Unset (0xFFFFFFFF) reads as -1 in retail's signed int32 store, + // so it takes the "cur < 0" branch same as any other negative. + if (current != Unset && current >= (uint)count) + normalized = 0u; + else if (current == Unset) + normalized = (uint)(count - 1); + else + return; + } + + _bindings.SetAppearanceIndex?.Invoke(slot.Value, normalized); + } + private void CycleStyle(Part part, int delta) { if (_disposed) @@ -378,14 +464,29 @@ internal sealed class CharacterCreationAppearancePage : IDisposable /// +1 positions (every real index, plus /// — decrementing from index 0 lands on Unset, /// incrementing from Unset lands on index 0, matching - /// ListenToElementMessage's cases 6 exactly). Every other - /// style spin has no decomp-observable Unset-cycling case (retail always - /// has a real 0-based index by the time the user can click — see - /// AP-214's RandomizeCharacter-at-open finding, which acdream - /// does not port this round) — an Unset start there is an edge case - /// retail itself never reaches, so the first click either direction just - /// starts cycling from index 0 rather than reconstructing an unfounded - /// wrap direction. + /// ListenToElementMessage's cases 6 exactly). + /// + /// + /// Fix round F1: every OTHER style spin ALSO has a decomp- + /// observable Unset-cycling case — it lives in the same switch the + /// headgear ring was ported from, at the shared decrement tail + /// (label_47f065/label_47f6d9, reached from Hair's + /// decrement case @0x0047f465-0x0047f486 and, inlined per-part, + /// from Eyes/Nose/Mouth/Shirt/Trousers/Footwear's own decrement cases + /// @0x0047f491-0x0047f65c): decrementing FROM Unset + /// (cur=-1 as signed int32) computes new = cur - 1 = -2, + /// which is < 0, so it wraps to count - 1 — the SAME + /// "wrap to the last index" shape headgear's own ring uses, just without + /// headgear's extra Unset ring position. Incrementing FROM Unset + /// computes new = -1 + 1 = 0, which is already in + /// [0, count), so it lands on style 0 — this half was already + /// correct. The prior doc here claimed "no decomp-observable + /// Unset-cycling case" and picked index 0 for BOTH directions; the + /// decomp refutes that for decrement. This matters in practice: AP-214's + /// honest-blank open leaves every non-headgear index Unset, so the + /// FIRST left-arrow click a user makes on this page hits this exact + /// path. + /// /// internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset) { @@ -401,7 +502,22 @@ internal sealed class CharacterCreationAppearancePage : IDisposable } if (current == Unset) - return 0u; + { + // Retail's per-part decrement/increment cases each recompute + // `new = cur + delta` on the RAW signed int32 (Unset = -1) and + // apply a SINGLE-STEP clamp (not a full modulo): new < 0 wraps + // to count-1, new >= count wraps to 0. Since every real caller + // only ever passes delta = -1/+1 here, evaluating that one-step + // clamp directly (rather than routing Unset through the general + // Mod() below, which assumes a valid starting index) reproduces + // retail exactly for both directions. + int fromUnset = -1 + delta; + if (fromUnset < 0) + return (uint)(count - 1); + if (fromUnset >= count) + return 0u; + return (uint)fromUnset; + } return (uint)Mod((int)current + delta, count); } @@ -455,6 +571,23 @@ internal sealed class CharacterCreationAppearancePage : IDisposable IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) { + // Fix round F2 item 2: gmCGAppearancePage::SetSelection + // @0x0047e260 resets the PREVIOUS current-part spin to state 1 + // (@0x0047e306, this->m_pCurSelection->vtable->SetState(1)) and sets + // the NEW one to state 6 (@0x0047e837, + // this->m_pCurSelection->vtable->SetState(6)) — a literal highlight + // toggle. UiButtonStateMachine.Normal/Highlight are already retail's + // own numeric ids 1/6 (see that class); IUiDatStateful.TrySetRetailState + // is the established seam for pushing a raw retail state id + // (CharacterCreationUiController.SetMasterPageState's own pattern). + foreach ((Part spinPart, UiButton spin) in _spins) + { + spin.TrySetRetailState( + spinPart == _currentPart + ? UiButtonStateMachine.Highlight + : UiButtonStateMachine.Normal); + } + ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart); uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance); for (int i = 0; i < _swatches.Length; i++) @@ -466,7 +599,12 @@ internal sealed class CharacterCreationAppearancePage : IDisposable ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); if (_shadeScroll is null) return; - _shadeScroll.Enabled = shadeSlot is not null; + // Fix round F2 item 3: gmCGAppearancePage::SetSelection HIDES the + // shade scrollbar for Eyes (@0x0047e862, SetVisible(0) — Eyes has no + // shade case in SetShade at all) and shows it otherwise + // (@0x0047e878, SetVisible(1)) — retail never DISABLES it, it + // removes it from the layout entirely. + _shadeScroll.Visible = shadeSlot is not null; if (shadeSlot is { } slot) { double shade = ShadeCurrent(slot, snapshot.Appearance); diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index 7e082c36..36126958 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -50,9 +50,11 @@ public sealed record CharacterCreationRuntimeBindings( /// through retail gmCharGenMainUI's authored retained layout — the /// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/ /// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this -/// slice builds. The Appearance (0x100003d4) and Summary -/// (0x100003d6) page roots are mounted but content-inert — CC6/CC5 -/// fill them (register TS-82). +/// slice builds. Fix round F6: the Appearance (0x100003d4) page root +/// is fully LIVE as of CC6b-MOUNT (); +/// only the Summary (0x100003d6) page root remains mounted but +/// content-inert — CC5 fills it (register TS-82, narrowed to Summary-only +/// at CC6b-MOUNT). /// /// /// Decomp anchors: root construction + child resolution @@ -639,10 +641,14 @@ internal sealed class CharacterCreationUiController : IDisposable break; } - // Random (0x100003cb): retail refuses on Skills (no - // RandomizeSkills primitive ported — AP-212) and on Summary - // (MakeRandomizeWarningDialog is CC5's); Appearance is this round's - // placeholder. + // Random (0x100003cb): fix round F5 — retail's DoRandom @0x004e7d70 + // case 3 fully ENABLES Random on Appearance (RandomizeClothing when + // m_eCurType == ECG_CHOICE_CLOTHES, else RandomizeAppearance); this + // is NOT a placeholder gap the way the old comment claimed. The + // disable here rests on the SAME unported-primitive gap AP-212 + // tracks for Skills (no RandomizeSkills) and Summary (no + // RandomizeCharacter) — RandomizeAppearance/RandomizeClothing are + // two more of AP-212's six named-but-unported primitives. _random.Enabled = _currentPage is not (Page.Skills or Page.Appearance or Page.Summary); // Finish stays ghosted regardless of page — Summary is a diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 145f168b..dfaab47c 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -624,8 +624,30 @@ public sealed class RetailUiRuntime : IDisposable /// Campaign CC slice CC6b-MOUNT: the Appearance page's authored /// viewport (0x100003bb) — null until the screen has mounted. - /// Mirrors 's own computed-through - /// shape. + /// + /// + /// Fix round F8 correction: this is NOT the same shape as + /// — that one is a plain + /// { get; private set; } auto-property assigned exactly once, + /// eagerly and non-retryably, inside MountInventory() (itself + /// called synchronously from Initialize(); if it fails the whole + /// call throws and the WHOLE UI runtime fails to + /// construct — there is no partial-failure case where + /// stays null while the rest of + /// the runtime comes up). THIS property is computed-through specifically + /// BECAUSE its underlying mount, _characterCreationMount + /// (), is explicitly + /// retryable/idempotent — ticked once per frame via + /// until it succeeds, tolerating a DAT/resource read that isn't ready + /// yet without failing the rest of the UI. + /// reads this property EXACTLY ONCE, during the single synchronous + /// startup composition pass (GameWindow.OnLoad) — unlike the + /// coordinator's own per-frame Tick, that one-shot GPU-resource + /// composition pass is NOT retried, matching every other private + /// viewport binding in that same method (paperdoll, creature appraisal) + /// — see that call site's own comment for the full disposition. + /// + /// internal UiViewport? ChargenPreviewViewportWidget => CharacterCreationController?.AppearanceViewport; diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 24b17201..7a798139 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -249,6 +249,12 @@ public sealed class UiScrollbar : UiElement return; } + if (ScalarChanged is not null) + { + DrawVerticalScalar(ctx, resolve); + return; + } + if (Model is not { } m) return; // Track background — TILED vertically (retail DrawMode=Normal). The native track @@ -313,6 +319,30 @@ public sealed class UiScrollbar : UiElement } } + /// + /// Fix round F11 (Campaign CC CC6b-MOUNT review): the mirror-image + /// counterpart of the horizontal scalar draw block above, for scalar-mode + /// bars authored VERTICAL (taller than wide) — retail's chargen shade + /// scrollbar (0x10000321) is one, measured against the installed + /// EoR dat (Width=33 Height=85). Retail's own + /// UIElement_Scrollbar is one class handling both a model-driven + /// list scroll and a scalar-value slider on EITHER axis; this class only + /// had the horizontal half of the scalar shape before this fix, so a + /// vertically-authored scalar bar (like the shade control) drew nothing + /// scalar-specific and fell through to the model-mode branch below, + /// which requires a a + /// scalar-mode bar never has. + /// + private void DrawVerticalScalar( + UiRenderContext ctx, Func resolve) + { + DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); + float thumbHeight = ScalarThumbExtent(resolve, Height); + float travel = MathF.Max(0f, Height - thumbHeight); + float y = travel * ScalarPosition; + DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight); + } + /// Draw a sprite stretched 1:1 to the dest rect. private void DrawSprite(UiRenderContext ctx, Func resolve, uint id, float x, float y, float w, float h) @@ -412,8 +442,17 @@ public sealed class UiScrollbar : UiElement if (e.Type == UiEventType.MouseMove) _hoveredButton = ButtonAt(e.Data1, e.Data2); - if (Horizontal && ScalarChanged is not null) - return OnScalarEvent(e); + // Fix round F11: retail's chargen shade scrollbar (0x10000321) is + // authored VERTICAL (measured against the installed dat), but a + // scalar-mode bar (ScalarChanged set, no Model) has always been + // possible on either axis in retail's own UIElement_Scrollbar. + // Gating this dispatch on Horizontal silently dropped every mouse + // event for a vertical scalar bar — it fell through the Horizontal + // Model branch below too, then hit "Model is not {} m => return + // false" since a scalar bar has no Model, so NOTHING ever routed to + // ScalarChanged in production for this orientation. + if (ScalarChanged is not null) + return Horizontal ? OnScalarEvent(e) : OnVerticalScalarEvent(e); if (Horizontal && Model is not null) return OnHorizontalModelEvent(e); @@ -590,14 +629,77 @@ public sealed class UiScrollbar : UiElement return false; } - private float ScalarThumbWidth(Func? resolve) + /// F11: the vertical mirror of — + /// same click-thumb-to-drag / click-track-to-jump shape, along Y/Height + /// instead of X/Width. Reuses (otherwise only + /// touched by the vertical MODEL-mode drag, mutually exclusive with + /// scalar mode on one instance) rather than adding a third offset field. + /// + private bool OnVerticalScalarEvent(in UiEvent e) + { + switch (e.Type) + { + case UiEventType.MouseDown: + { + float thumbHeight = ScalarThumbExtent(SpriteResolve, Height); + float travel = MathF.Max(1f, Height - thumbHeight); + float thumbY = travel * ScalarPosition; + float y = e.Data2; + // OP5 re-check R2 (mirrored from OnScalarEvent): latch + // before the jump so the jump's own tick defers its flush + // to MouseUp's DragCompleted. + _draggingThumb = true; + if (y >= thumbY && y <= thumbY + thumbHeight) + { + _dragOffsetY = y - thumbY; + } + else + { + _dragOffsetY = thumbHeight * 0.5f; + ChangeScalarPosition((y - _dragOffsetY) / travel); + } + return true; + } + + case UiEventType.MouseMove when _draggingThumb: + { + float thumbHeight = ScalarThumbExtent(SpriteResolve, Height); + float travel = MathF.Max(1f, Height - thumbHeight); + ChangeScalarPosition(((float)e.Data2 - _dragOffsetY) / travel); + return true; + } + + case UiEventType.MouseUp: + { + bool wasDragging = _draggingThumb; + _draggingThumb = false; + _pressedButton = EndButton.None; + if (wasDragging) DragCompleted?.Invoke(); + return true; + } + } + + return false; + } + + private float ScalarThumbWidth(Func? resolve) => + ScalarThumbExtent(resolve, Width); + + /// F11: generalized over so + /// can size the thumb along the + /// authored axis (native sprite width for a horizontal bar, native + /// sprite height for a vertical one) instead of assuming horizontal. + /// + private float ScalarThumbExtent( + Func? resolve, float axisLength) { if (resolve is not null && ThumbSprite != 0) { - var (_, width, _) = resolve(ThumbSprite); - if (width > 0) return MathF.Min(width, Width); + var (_, width, height) = resolve(ThumbSprite); + int native = Horizontal ? width : height; + if (native > 0) return MathF.Min(native, axisLength); } - return MathF.Min(16f, Width); + return MathF.Min(16f, axisLength); } private void ChangeScalarPosition(float position) diff --git a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs index fe1c7579..0142d4a9 100644 --- a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs +++ b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs @@ -17,6 +17,21 @@ namespace AcDream.Content.CharGen; /// live preview re-composes on every appearance change, and the same /// PalSet/ClothingTable ids repeat constantly across heritages, genders, and /// re-selections within one session. +/// +/// +/// NOT thread-safe on its own (fix round F7, CC6b-MOUNT review): +/// / do a lazy raw +/// _dats.Get<T>() read on first use per id — and the shared +/// DatCollection every sibling in this codebase guards with the +/// process-wide DAT lock is itself NOT thread-safe +/// (feedback_phase_a1_hotfix_saga.md). Every call site MUST hold that +/// same lock (ChargenPreviewController's _datLock, the +/// composition root's d.DatLock) around calls into this class, exactly +/// like every other DAT-touching call in this codebase already does. This +/// class's own caches only +/// protect the CACHE from concurrent mutation — they do nothing for the +/// underlying DatCollection read the cache miss triggers. +/// /// public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource { diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs index 0e291b6f..af7892f8 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Tests.UI.Layout; // InstalledDatFactAttribute (fix round F13) using AcDream.Content; using AcDream.Content.CharGen; using AcDream.Content.Vfx; @@ -22,6 +23,18 @@ namespace AcDream.App.Tests.Rendering; /// REAL dat-backed / /// so ChargenAppearanceFactory.TryCompose and /// ChargenPreviewEntityBuilder.TryBuildAnimated actually run. +/// +/// +/// Fix round F13: every case uses +/// (shared with +/// CharacterCreationLiveDatTests/CharacterManagementLiveDatTests) +/// instead of plain [Fact]. Before this fix, a bare [Fact] plus +/// 's own if (!TryOpen(...)) return; guard made +/// all six cases pass SILENTLY with zero assertions run whenever +/// ACDREAM_DAT_DIR was unavailable — indistinguishable in the test +/// runner's summary from an actual passing run. The attribute now reports +/// those runs as Skipped so the counts show "ran" separately from "no-op'd". +/// /// public sealed class ChargenPreviewControllerTests { @@ -31,7 +44,7 @@ public sealed class ChargenPreviewControllerTests private const uint AluvianId = 1u; private const uint GearknightId = 6u; - [Fact] + [InstalledDatFact] public void Rebuild_SameSelectionTwice_IsANoOpSecondTime() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -54,7 +67,7 @@ public sealed class ChargenPreviewControllerTests } } - [Fact] + [InstalledDatFact] public void Rebuild_HeritageChange_ResetsCameraToTheNewHeritagesDefaultEye() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -101,7 +114,7 @@ public sealed class ChargenPreviewControllerTests /// changes ONLY the appearance selection (same heritage, same gender) /// must NOT snap the camera back to the heritage default. /// - [Fact] + [InstalledDatFact] public void Rebuild_AppearanceOnlyChange_LeavesTheCameraUntouched() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -129,7 +142,7 @@ public sealed class ChargenPreviewControllerTests } } - [Fact] + [InstalledDatFact] public void Rebuild_PreservesZoomState_AcrossAnAppearanceOnlyChange() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -160,7 +173,7 @@ public sealed class ChargenPreviewControllerTests } } - [Fact] + [InstalledDatFact] public void Rebuild_ThenRender_SeedsTheEntityHeadingToTheRetailDefault180Degrees() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -191,7 +204,7 @@ public sealed class ChargenPreviewControllerTests } } - [Fact] + [InstalledDatFact] public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -230,6 +243,14 @@ public sealed class ChargenPreviewControllerTests private static (ChargenOptions, ChargenAppearanceCatalog) LoadFixture(IDatReaderWriter dats) => (ChargenTableReader.Load(dats), new ChargenAppearanceCatalog(dats)); + /// + /// Fix round F13: already gates + /// every case above this call, so datDir is null should not be + /// reachable in practice once a case actually runs — this stays as + /// defense-in-depth (a DAT directory that exists but a corrupt/renamed + /// client_portal.dat the attribute's own lighter check missed) + /// rather than a silent no-assertions pass. + /// private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter) { string? datDir = CornerFloodReplayTests.ResolveDatDir(); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 6fb39999..0c9c6cd8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -359,7 +359,17 @@ public sealed class CharacterCreationLiveDatTests CharacterCreationAppearancePage.FootwearSpinId, }) { - AssertButton(appearanceRoot, spinId); + UiButton spin = AssertButton(appearanceRoot, spinId); + // Fix round F2 item 2: the current-part highlight + // (TrySetRetailState(Normal/Highlight)) only has a visible + // effect through UiButton's ToggleBehavior branch when the + // authored spin actually sets DAT property 0x0B — measured + // (not assumed, matching this file's own discipline for the + // arrow geometry above) True for all nine spins against the + // installed EoR dat. Pinned so a future DAT revision that + // drops it shows up here instead of as a silently-dead + // highlight. + Assert.True(spin.ToggleBehavior, $"spin 0x{spinId:X8} must author ToggleBehavior for the current-part highlight to work."); } // Every color-wheel-family id resolves through EXISTING @@ -367,8 +377,20 @@ public sealed class CharacterCreationLiveDatTests // Type-3 fallback) — the risk-item-4 scouting result, pinned. foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) AssertButton(appearanceRoot, swatchId); - Assert.IsType( + UiScrollbar shadeScroll = Assert.IsType( UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ShadeScrollId)); + // Fix round F11: measured (not assumed) against the installed EoR + // dat — the shade scrollbar (0x10000321) is authored VERTICAL + // (33x85, taller than wide). Before this fix, UiScrollbar.OnEvent + // only routed to ScalarChanged when Horizontal was true, so mouse + // input on this control never reached SetShadeFromScalar in + // production. Pinned so a future DAT revision that flips this + // orientation is caught here rather than silently reintroducing the + // dead-input bug (UiScrollbar's OnVerticalScalarEvent handles this + // orientation now, but ONLY this orientation gets exercised in + // production). + Assert.False(shadeScroll.Horizontal); + Assert.True(shadeScroll.Height > shadeScroll.Width); Assert.IsType( UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.GradCircleId)); @@ -426,6 +448,15 @@ public sealed class CharacterCreationLiveDatTests Assert.NotNull(increment); Assert.Equal(80f, decrement!.X); Assert.Equal(127f, increment!.X); + // Fix round F10: the arrow WIDTHS are what actually derive + // CharacterCreationAppearancePage.IncrementZoneEnd (174 = + // IncrementZoneStart 127 + this measured 47px width) — X alone + // pins the LEFT edge of each zone, not where the increment zone + // ends and the select-as-current-part body zone begins. Measured + // against the installed EoR dat: both arrows are 47px wide, + // uniformly, across all nine spins. + Assert.Equal(47f, decrement.Width); + Assert.Equal(47f, increment.Width); } } @@ -457,7 +488,7 @@ public sealed class CharacterCreationLiveDatTests private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); - private static void AssertButton(UiElement root, uint elementId) => + private static UiButton AssertButton(UiElement root, uint elementId) => Assert.IsType(UiElement.FindDescendant(root, elementId)); private static ImportedLayout BuildSelected( diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 6f797a4a..567391bb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -527,21 +527,88 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); } + /// + /// Fix round F1: this test previously asserted index 0 here, pinning a + /// doc claim ("no decomp-observable Unset-cycling case") the decomp + /// refutes. Hair's own decrement case + /// (gmCGAppearancePage::ListenToElementMessage @0x0047f465-0x0047f086, + /// the same shared tail the headgear ring reuses at + /// label_47f065/label_47f6d9) computes + /// new = cur - 1 on the raw signed int32 (Unset = -1), giving + /// new = -2; since -2 < 0 it wraps to count - 1, + /// NOT 0 — see 's + /// own corrected doc. The fixture's Hair style count is 3 + /// (), so the expected + /// landing index is 2. + /// [Fact] - public void AppearanceSpin_DecrementZoneClick_FromUnset_StartsAtStyleZero() + public void AppearanceSpin_DecrementZoneClick_FromUnset_WrapsToLastStyle() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); SelectAluvianMale(environment); - // Non-headgear spins have no decomp-observable Unset-cycling case - // (retail always has a real 0-based index by the time the user can - // click — CycleIndex's own citation) — a first click from Unset in - // EITHER direction just starts cycling at style 0, not a ring. environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10); Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(2u, environment.Runtime.LastAppearanceIndex); + } + + /// + /// Fix round F10: pins the SELECT zone — x=[174,200), right of the + /// increment arrow's own x=[127,174) (live-DAT-measured, both arrows + /// 47px wide — CharacterCreationLiveDatTests) — as a body click + /// that selects the part WITHOUT invoking another style cycle, once the + /// part already holds a real (non-Unset) index. Distinguishes the third + /// OnClickAt zone from the two arrow-zone tests above, which no + /// prior test isolated. + /// + [Fact] + public void AppearanceSpin_SelectZoneClick_SelectsPartWithoutChangingIndex() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + // Establish a real Hair index first (increment zone, x=150). + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10); Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + int callsAfterCycle = environment.Runtime.AppearanceIndexCallCount; + + // x=180 is inside [174,200) — past the increment arrow's own zone, + // still inside the 200px-wide spin — the spin's own BODY, not + // either arrow. + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10); + + // Already in [0,count) — NormalizeChoiceOnSelect (F1) is a no-op, + // so the select zone must not re-invoke SetAppearanceIndex. + Assert.Equal(callsAfterCycle, environment.Runtime.AppearanceIndexCallCount); + Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + } + + /// + /// Fix round F1: pins the body-click normalize-and-write-back + /// ('s own + /// NormalizeChoiceOnSelect doc) for the case that's actually + /// reachable in acdream — a part still Unset (AP-214 honest-blank open) + /// gets clicked in its SELECT zone (not an arrow) — wraps to + /// count-1 and writes it back, exactly like a decrement click + /// would, even though no arrow was pressed. + /// + [Fact] + public void AppearanceSpin_SelectZoneClick_FromUnset_NormalizesToLastStyle() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + // Hair starts Unset (AP-214 honest-blank). x=180 is the select + // zone, not either arrow. + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10); + + Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(2u, environment.Runtime.LastAppearanceIndex); // count-1, fixture has 3 hair styles. } /// CharGenState::SetHeadgearStyle's decomp-derived @@ -576,7 +643,12 @@ public sealed class CharacterCreationUiControllerTests [InlineData(1u, -1, 3, false, 0u)] [InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start. [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)] - [InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 0u)] + // Fix round F1: decrement-from-Unset wraps to count-1 (2), not 0 — the + // decomp's shared decrement tail (label_47f065/label_47f6d9) computes + // new = cur - 1 = -2 on the raw signed int32, which is < 0, so it wraps + // to count-1 exactly like headgear's own ring does for its non-Unset + // range. See CycleIndex's own corrected doc. + [InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 2u)] [InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset. [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0. [InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset. diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index c59fa3d8..7a1b0441 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -103,6 +103,43 @@ public class UiScrollbarTests Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45))); } + /// + /// Fix round F11 (Campaign CC CC6b-MOUNT review): retail's chargen shade + /// scrollbar (0x10000321) is authored VERTICAL (measured 33x85 + /// against the installed dat — see + /// CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport), + /// but before this fix UiScrollbar.OnEvent only routed to + /// ScalarChanged when Horizontal was true — a vertical + /// scalar bar's clicks fell through to the Model-mode branch, which + /// returns false with no set, so the + /// shade control never fired in production. Mirrors + /// + /// exactly, transposed onto Y/Height/Data2. + /// + [Fact] + public void VerticalScalar_clickAndDrag_updatesNormalizedValue() + { + float value = 1f; + var bar = new UiScrollbar + { + Width = 14f, + Height = 90f, + Horizontal = false, + ScalarChanged = next => value = next, + }; + bar.SetScalarPosition(1f); + + Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data2: 8))); + Assert.Equal(0f, value, 3); + Assert.Equal(0f, bar.ScalarPosition, 3); + + Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data2: 45))); + Assert.Equal(0.5f, value, 3); + Assert.Equal(0.5f, bar.ScalarPosition, 3); + + Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data2: 45))); + } + // ── OP5 review fix S1: the drag-end seam (IsDragging / DragCompleted) ──── [Fact]