diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index fc44fb20..1e16834c 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) — 158 active rows (AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 160 active rows (AP-223/AP-224/AP-225 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal, the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing, and the Summary name field's 32-vs-33 length-threshold reconciliation; AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -207,6 +207,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-225 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary page's name field).** Retail's chargen name buffer is `char name[33]` (32 usable chars + null terminator — `CharGenState`'s own struct field, `acclient.h`). The UI-side pre-commit length check at `gmCGSummaryPage::ListenToElementMessage @ 0x0047bf40` (`~0x0047bfd1`) compares the raw input against the literal `0x21` (33), rejecting anything longer — but the exact base of that decompiled comparison (visible character count vs. an internal length-prefix accounting the decompiler didn't resolve cleanly) is not fully certain from the pseudo-C. `CharacterCreationSummaryPage`'s own `MaxNameLength` uses 32, matching the ALREADY-ESTABLISHED `RuntimeCharacterCreationState.TrySetName` storage cap (CC3), rather than trusting the ambiguous 1-off literal over that reviewed contract — a name of exactly 33 characters is the only value where the two thresholds could disagree. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`MaxNameLength`) | Internal consistency between the UI-level reject-and-revert threshold and the Runtime storage cap is more valuable than an unverified 1-character decomp literal — a real divergence here would show up as "the field accepts 33 characters but the create sends 32," which this alignment prevents by construction. | If retail's actual usable cap is genuinely 33 (not 32), a 33-character name that should be accepted gets rejected with the too-long dialog instead — a narrow, one-character-wide UX mismatch, never a data-corruption risk (the wire format truncates to whatever `TrySetName` already stores either way). | `gmCGSummaryPage::ListenToElementMessage @ 0x0047bf40`; `CharGenState.name[33]` (`acclient.h`); `RuntimeCharacterCreationState.TrySetName` (CC3) | +| AP-224 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary listbox content).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` walks FOUR skill buckets (Specialized, Trained, UseableUntrained, UnuseableUntrained) and lists every skill name in each, via a nested loop over `skillRecordList`. `CharacterCreationSummaryPage.AddSkillBucket` lists Specialized and Trained only, skipping the two Untrained buckets — mirroring AP-213's own already-accepted Skills-page simplification precedent (same class of cut: presentation grouping, not correctness). Health/Stamina/Mana values reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas (Health=Endurance/2, Stamina=Endurance, Mana=Self) rather than this page's OWN `SetSummaryText` call site, whose two `GetAttribute` calls for Health/Stamina both show a literal attribute index of `2` in the decompiled pseudo-C — a decompiler-ambiguous pair the cleaner Profession-page citation sidesteps rather than reproduces uncritically. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`RebuildListbox`, `AddSkillBucket`) | The two Untrained buckets would list the ~40+ skills the player did NOT touch — volume without decision-relevant information for a pre-Finish review screen; every skill's actual cost/level data remains identical and inspectable on the Skills page itself. The Health/Stamina/Mana citation choice favors a decomp site with an unambiguous formula over one with a decompiler artifact. | A player scanning Summary for "what am I NOT trained in" has to go back to the Skills page instead of seeing it listed here — a discoverability gap, not a correctness gap; the row TEMPLATE mechanism itself (three retail row types: single-line, header, key/value pair) is ported exactly, live-DAT-probe-confirmed, not simplified. | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0`; `CharacterCreationProfessionPage.Refresh`'s own `UpdateAttributeValues @ 0x00482450` citation | +| AP-223 | **Filed 2026-08-15 at Campaign CC slice CC5 (the F12 amendment's own explicit ask — see AP-214's now-retired "Latent Finish-path interaction" note).** `RuntimeCharacterCreationState.TryBeginFinish` gains a NEW local refusal, `HeritageOrGenderUnset`, checked right after the empty-name check. Retail's own `gmCharGenMainUI::DoFinish @ 0x004E9170` has NO such check in the decompiled code — but it doesn't need one: `RandomizeCharacter` at ctor time (now ported, see AD-101/AP-212/AP-214's history) guarantees heritage+gender are ALWAYS real by the time any page — including Summary/Finish — exists. This refusal is acdream's OWN defensive backstop for a caller that reaches `Finish` without that screen-open roll ever having run (a headless bot driving `RuntimeCharacterCreationState` directly, or a future caller that bypasses `CharacterCreationUiController.Open`). Under the ordinary UI it is normally unreachable (the roll always fires first). | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`, `TryBeginFinish`) | Retail's own guarantee is architectural (a roll that always runs before any page exists), not a runtime check — acdream's UI reproduces the roll (`CharacterCreationUiController.Open` → `RollOpeningCharacter`) but a direct Runtime caller could still skip it, so a local refusal is the honest choice over silently sending a heritage-0/gender-0 wire request ACE would likely reject anyway for unrelated reasons. | A caller that bypasses the normal screen-open path and calls `Finish` before ever selecting heritage/gender gets a local refusal instead of a wire round-trip to discover the same failure — no server-visible consequence either way. | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`, the ctor-time roll); `CharGenState::RandomizeCharacter @0x005c6d80`; `gmCharGenMainUI::DoFinish @ 0x004E9170` (no heritage/gender check present) | | AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-window indicator buttons (`0x10000522`-`0x10000525`) resolve their own correct `ActiveState="Normal"` at construction, then get blanked to `""` moments later in the SAME `LayoutImporter.Build` call: the indicator column's backing panel (`0x10000600`) authors `PassToChildren=true` on its own empty DirectState (confirmed live: `States[0xFFFFFFFF].PassToChildren == true`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists | | ~~AP-201~~ | **RETIRED 2026-08-11 at the Campaign OP gate-3 fix round (closes #371).** UiScrollablePanel now marks ClipsChildren=true (the draw walk and hit-test both route through UiRenderContext.PushClip, which existed by retirement time) and its cull predicate keeps any INTERSECTING row visible - a straddling row renders its visible slice instead of vanishing whole. The user-observed symptom this row predicted (the Chat tab per-window filter blocks reading as MISSING at the default scroll offset, gate 3) is the exact acceptance evidence. Original filing follows for the record: filed at the OP5 review-fix round (S2), predates OP5 but was made user-visible by it. `UiTemplateListBox`'s internal row viewport (`UiScrollablePanel.LayoutScrollableChildren`) culls a child WHOLE — `child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f` — rather than clipping the visible portion of a row that straddles the viewport edge, because the UI renderer has no scissor stack. Retail's own `UIElement_ListBox`/scroll-region rendering clips partially-visible rows at the pixel boundary, same as any native scroll view. Every row in this viewport was 8-36px until Campaign OP slice OP5 added five self-sized filter blocks (12x20=240px / 13x20=260px, AP-195) to the Chat tab's ~560px viewport; a 240-260px block straddling the viewport edge at a given scroll offset now disappears ENTIRELY (a visible "pop") instead of clipping, where the pre-OP5 8-36px rows made the same all-or-nothing cull read as ordinary row-granular scrolling. | `src/AcDream.App/UI/UiScrollablePanel.cs:69` (the cull predicate); consumed by `src/AcDream.App/UI/UiTemplateListBox.cs` (`Viewport`) — the Character/Chat/Config Options-panel tabs and any other controller-built row list sharing this viewport | A scissor stack does not exist anywhere in the retained-UI renderer yet (class's own doc comment, `UiScrollablePanel.cs:8-12`, predates this row); whole-row culling is a correct, cheap stand-in for every list whose rows are small relative to the viewport, which was true for every consumer before OP5. | A tall block (any future row taller than roughly the viewport's own height, not just OP5's filter blocks) can vanish completely for a range of scroll offsets instead of showing a partial view — the OP5 gate script's own step 2 documents the exact symptom so it is not mistaken for a self-sizing regression (`docs/research/2026-08-11-campaign-op-test-script.md`). Scrolling further always restores the block whole; no data or state is lost, only the presentation pops. | No scissor-stack retail oracle needed — this is a stand-in for ordinary native clip-rect rendering every GUI toolkit (including retail's own) provides; issue #371 tracks adding a real per-row clip rect to `UiScrollablePanel` | @@ -397,12 +400,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`) and creature-appraisal in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of the CC6b-MOUNT fix round's blast radius; the re-reviewer accepted the narrower diagnostic-only fix (R1) as sufficient for this round with this row as the tracked follow-up. | On the specific unlucky frame where the coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose 3D preview zoom/rotate controls are dead for the ENTIRE session with no visible error beyond the (narrowed) console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of this binding (CC5 or a follow-up slice) should close. | `src/AcDream.App/Composition/LivePresentationComposition.cs:996-1104` (chargen preview block's own F8 disposition comment); `RetailUiRuntime.ChargenPreviewViewportWidget`'s doc comment (retry-vs-one-shot contrast) | | AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | -| AP-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 (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-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `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) +## 4. Temporary stopgap (TS) — 49 active rows (TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); 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) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -414,7 +416,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-78 | "Use Main Pack as Default for Picking Up Items" (`PlayerOption MainPackPreferred`) has no acdream consumer — retail's `CPlayerSystem::PlaceInBackpack @0x0055d8c0` chooses which container a picked-up item lands in client-side; acdream's pickup path (`SendPickup`) has no client-side preferred-container selection at all today. | item-pickup path (`src/AcDream.App/UI/ItemInteractionController.cs` and siblings) — no consumer wired | A real consumer needs the client-side container-preference decision retail's `PlaceInBackpack` makes, which does not exist in the current pickup flow — future scope. | Toggling the option writes the bit and dirties/auto-saves it correctly, but item pickups route exactly as before (server-decided placement). | `CPlayerSystem::PlaceInBackpack @0x0055d8c0` | | TS-79 | Group D (plan §4 OP4): "Salvage Multiple Materials at Once" (`SalvageMultiple`) and "Disable House Restriction Effects" (`DisableHouseRestrictionEffects`) have no acdream consumer — acdream has no salvage UI (`gmSalvageUI`) and no housing subsystem (`ACCWeenieObject::CanMoveInto`) for either option to gate. | no consumer — both are Character-tab rows, wire+store only | Both require whole unbuilt subsystems (salvage crafting UI; player housing); inventing a stand-in is out of scope for a settings-panel slice. | Toggling either option writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes (both are also currently unreachable — no salvage UI, no housing). | `gmSalvageUI::IsItemSuitable @0x004cb040`; `ACCWeenieObject::CanMoveInto @0x0058da40` | | TS-80 | "Share Fellowship Experience and Luminance" (`PlayerOption FellowshipShareXP`) is Group D's one CLIENT-SOURCED option (character-options-map.md §3): retail's `gmFellowshipUI::CreateFellowship` reads the option value and puts it directly in the fellowship-CREATE wire action; ACE takes XP-sharing from that packet field, never from the stored `CharacterOptions1` bit (`Entity/Fellowship.cs:31,53-54`). Storing the bit alone (this slice's row) is necessary but not sufficient — acdream's own fellowship-create action does not yet read it into the create packet. **PARTIALLY NARROWED 2026-08-12 at Campaign FA slice FA2: the wire mechanism now exists end-to-end — `IRuntimeFellowshipCommands.Create(gen, name, shareXp)` takes and sends `shareXp` on `0x00A2` — but no caller reads `FellowshipShareXP` into that parameter yet (the create dialog is FA4 scope); the risk below is unchanged until that UI lands.** | fellowship-create action (`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs` `Create`; `src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs` `Create`) — takes `shareXp` as an explicit caller-supplied argument, not yet fed from the option bit | Filed rather than silently assumed correct — a bit that LOOKS wired (toggles, persists, sends `0x0005`) but is never actually consulted by fellowship creation would silently share/withhold XP incorrectly the moment a fellowship is created. | Toggling the option and then creating a fellowship may not honor the toggle — the created fellowship's actual XP-share setting depends on whatever caller value FA4's create dialog passes, unaudited by this slice. | `gmFellowshipUI::CreateFellowship` (address not captured this slice); ACE `Entity/Fellowship.cs:31,53-54` | -| TS-82 | **Filed 2026-08-15 at Campaign CC slice CC4. NARROWED to Summary-only 2026-08-15 at Campaign CC slice CC6b-MOUNT.** The Summary (`0x100003d6`, `gmCGSummaryPage`) page root mounts as an EMPTY, content-inert placeholder — visible/reachable through the master shell's free tab navigation (a player can click the Summary tab and land on a blank page) but with none of retail's own controls built: no name field, no summary listbox, no static preview. Explicitly scoped to CC5 (Summary + the Finish gate's real UI). **The Appearance page (`0x100003d4`, `gmCGAppearancePage`) is CLOSED OUT OF THIS ROW as of CC6b-MOUNT** — it now has real gender/Face-Clothes/spin/color-swatch/shade/zoom/rotate controls and a live 3D preview (`CharacterCreationAppearancePage`), so it is no longer content-inert. The master shell already ports retail's OWN visibility/state-toggle/tab-selection mechanics for the Summary page faithfully — only its CONTENT is stopgapped. | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`_summaryPageRoot`, mounted but no page controller attached) | The explicitly sequenced follow-on slice CC5 owns Summary's content; building it here would duplicate work already scoped to that slice and risk drifting from its own DAT/decomp research (the name-input filter, the summary listbox, the static preview). | A player reaching Summary via free tab navigation sees an empty page instead of retail's controls; Finish stays ghosted (**review fix round F11 (2026-08-15) — corrected cross-reference: this row's OWN CC5 dependency, not AP-211**, which is an unrelated roster-slot-cap local refusal — `CharacterCreationUiController`'s `_finish.OnClick = null` ctor comment names this row directly as the reason Finish has no handler this slice) so no create can complete through this screen until CC5 wires the Summary page's name field and the real Finish gate. | `gmCGSummaryPage` (InitializePage @ 136566 per the campaign plan); `docs/plans/2026-08-15-character-creation-campaign.md` (Slice CC5) | | TS-81 | `0x027A AllegianceLoginNotification`'s retail-faithful two-line chat text (lane C §1.6/§7.1: "is the guid in my cached profile" gate, then a logged-on/logged-off line) is NOT emitted. `RuntimeAllegianceState.ApplyLoginNotification` bumps the snapshot revision only. Retail's own handler chain (`ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0` → `CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330` → `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220`) resolves its logged-on/logged-off string via two symbols the Binary Ninja decompiler mis-labels as `gmAllegianceUI::\`vftable'.RecvNotice_PrevSpellTab`/`RecvNotice_UpdateSpellComponents` — a decompiler artifact (the address holds a DAT string-table reference, not those vtable slots; same class CLAUDE.md's BN-literal-0 caution warns about) that must be resolved via `compute_str_hash`/DAT string-table lookup, not guessed. Filed rather than inventing English for the two lines. | `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (`ApplyLoginNotification`) | CLAUDE.md's "no invented user-visible English ever" rule — the candidate strings are BN-mislabeled and unverified from primary source; guessing here is exactly the negligence the workflow rules forbid. | A player never sees retail's "X has logged on/off" allegiance notice; the event still fires and updates Runtime state (usable for a future bot/UI poll), just with no chat line. | `ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0`; `CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330`; `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220` | | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | | ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 187ba0e4..98b36370 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -252,7 +252,7 @@ the user gate. | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | -| CC5 | — | | | | +| CC5 | CODE-COMPLETE 2026-08-15 | (this session's commit(s) — see git log for `feat(chargen): Campaign CC slice CC5`) | OWED (dual-lens review pending) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied` (Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` already treats them as a silent reset with no `RuntimeCharacterCreationRejection` produced at all); dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | | CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **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. | diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 6f1deeb5..2d31ed23 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -540,7 +540,8 @@ internal sealed class FrameRootCompositionPhase new PrivateEntityViewportFrameGroup( live.PaperdollPresenter, live.CreatureAppraisalPresenter, - live.ChargenPreviewController), + live.ChargenPreviewController, + live.SummaryPreviewController), retainedGameplayUi, // The ImGui developer-tools frontend was removed at Campaign V // slice V11; this optional hook is unbound until a follow-up diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 53ab9be4..c24fcbeb 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -1007,6 +1007,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory DatStringResolver.ComputeHash(key)); } }, + SetName: late.GameRuntime.CharacterCreationSetName, + AcknowledgeRejection: late.GameRuntime.CharacterCreationAcknowledgeRejection, + RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter, + RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, + RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, OpenOnStart: d.Options.OpenCharacterCreationOnStart) : null); RetailUiRuntime runtime = lease.Mount( diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index f61e0651..185032e5 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -241,6 +241,28 @@ internal sealed class DeferredGameRuntimeStateCommands Invoke((commands, generation) => commands.CharacterCreation.SetShade(generation, slot, value)); + // ── Campaign CC slice CC5: Summary page + RandomizeCharacter commands ── + + public RuntimeCommandResult CharacterCreationSetName(string name) => + Invoke((commands, generation) => + commands.CharacterCreation.SetName(generation, name)); + + public RuntimeCommandResult CharacterCreationAcknowledgeRejection() => + Invoke((commands, generation) => + commands.CharacterCreation.AcknowledgeRejection(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeCharacter() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeCharacter(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeAppearance() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeAppearance(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeClothing() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeClothing(generation)); + // ── Campaign FA slice FA4: fellowship page commands ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index a598663e..a623d31e 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -132,6 +132,11 @@ internal sealed record LivePresentationResult( // a leased composition resource, mirroring PaperdollViewportRenderer). ChargenPreviewRenderer? ChargenPreviewRenderer, ChargenPreviewController? ChargenPreviewController, + // Campaign CC slice CC5: the Summary page's own gmCG3DView instance — + // a SEPARATE leased renderer/controller pair, same split reasoning as + // the Appearance preview fields immediately above. + ChargenPreviewRenderer? SummaryPreviewRenderer, + ChargenPreviewController? SummaryPreviewController, WbFrustum EnvCellFrustum, EnvCellRenderer? EnvCellRenderer, LandblockPresentationPipeline LandblockPipeline, @@ -1102,6 +1107,82 @@ internal sealed class LivePresentationCompositionPhase + "time — the Appearance page's zoom/rotate controls and " + "3D preview will not function this session."); } + + // Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance + // (gmCGSummaryPage::InitializePage @0x0047bbf0, confirmed a SEPARATE + // instance from the Appearance page's own during the CC6b-MOUNT + // review) — same one-shot binding shape as the Appearance preview + // immediately above (AP-221's own disposition applies here too: a + // DAT/resource read not ready on this exact composition frame means + // the Summary preview stays permanently unbound for the session, + // same tracked follow-up as the Appearance preview). No zoom/rotate + // control surface is wired — retail's Summary page has no such + // buttons (only StartAnimation's idle loop and a fixed 180° + // heading), so this controller's ZoomIn/RotateClockwise etc. simply + // never get called. + CompositionAcquisitionScope.CompositionAcquisitionLease< + ChargenPreviewRenderer>? summaryPreviewLease = null; + ChargenPreviewController? summaryPreviewController = null; + if (dispatcherLease.Resource is { } summaryDispatcher + && interaction.RetainedUi?.Runtime.SummaryPreviewViewportWidget is { } summaryViewport) + { + var summaryCamera = new ChargenPreviewCamera(); + summaryPreviewLease = scope.Acquire( + "summary preview viewport", + () => new ChargenPreviewRenderer( + worldPassScope + ?? throw new InvalidOperationException( + "The graphics backend must publish a world pass scope."), + host.GpuDevice, + host.GpuFrameLifetime, + summaryDispatcher, + foundation.SceneLighting!, + foundation.TextureCache, + foundation.MeshAdapter!, + camera: summaryCamera), + static value => value.Dispose()); + IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer; + summaryViewport.Renderer = summaryPreviewLease.Resource; + bindings.AdoptRelease( + "summary preview viewport target", + () => + { + if (ReferenceEquals(summaryViewport.Renderer, summaryPreviewLease.Resource)) + summaryViewport.Renderer = previousSummaryRenderer; + }); + + var summaryCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats); + summaryPreviewController = new ChargenPreviewController( + summaryPreviewLease.Resource, + summaryCamera, + new RetailChargenPreviewFrameView( + summaryViewport, + new RetailSummaryPreviewPageVisibility(interaction.RetainedUi.Runtime)), + content.Dats, + content.AnimationLoader, + summaryCatalog, + summaryCatalog, + d.DatLock); + interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController; + bindings.AdoptRelease( + "summary preview control", + () => + { + if (ReferenceEquals( + interaction.RetainedUi.Runtime.SummaryPreviewControl, + summaryPreviewController)) + { + interaction.RetainedUi.Runtime.SummaryPreviewControl = null; + } + }); + } + else if (dispatcherLease.Resource is not null && interaction.RetainedUi is not null) + { + Console.WriteLine( + "[UI] summary preview viewport unavailable at composition " + + "time — the Summary page's 3D preview will not function " + + "this session."); + } Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); @@ -1410,6 +1491,8 @@ internal sealed class LivePresentationCompositionPhase creatureAppraisalPresenter, chargenPreviewLease?.Resource, chargenPreviewController, + summaryPreviewLease?.Resource, + summaryPreviewController, envCellFrustum, envCellLease.Resource, landblockPipeline, diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs index 94998b47..a0c16d53 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -78,6 +78,19 @@ internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVi public bool IsVisible => _runtime.IsChargenPreviewPageVisible; } +/// Campaign CC slice CC5: the Summary page's own visibility gate — +/// same shape as , reading +/// RetailUiRuntime.IsSummaryPreviewPageVisible instead. +internal sealed class RetailSummaryPreviewPageVisibility : IChargenPreviewPageVisibility +{ + private readonly AcDream.App.UI.RetailUiRuntime _runtime; + + public RetailSummaryPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public bool IsVisible => _runtime.IsSummaryPreviewPageVisible; +} + /// Retained-UI visibility + texture publication, mirroring /// RetailPaperdollFrameView. internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7fad2da9..4a0bc6aa 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -434,6 +434,10 @@ public sealed class GameWindow : // viewports above. private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer; private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController; + // Campaign CC slice CC5: the Summary page's own gmCG3DView instance — + // a SEPARATE renderer/controller pair from the Appearance preview above. + private AcDream.App.Rendering.ChargenPreviewRenderer? _summaryPreviewRenderer; + private AcDream.App.Rendering.ChargenPreviewController? _summaryPreviewController; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession; @@ -1123,6 +1127,8 @@ public sealed class GameWindow : _creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter; _chargenPreviewRenderer = result.ChargenPreviewRenderer; _chargenPreviewController = result.ChargenPreviewController; + _summaryPreviewRenderer = result.SummaryPreviewRenderer; + _summaryPreviewController = result.SummaryPreviewController; _envCellFrustum = result.EnvCellFrustum; _envCellRenderer = result.EnvCellRenderer; _landblockPresentationPipeline = result.LandblockPipeline; @@ -1770,6 +1776,8 @@ public sealed class GameWindow : _creatureAppraisalViewportRenderer, _chargenPreviewRenderer, _chargenPreviewController, + _summaryPreviewRenderer, + _summaryPreviewController, _wbDrawDispatcher, _envCellRenderer, _portalDepthMask, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index ca95a57e..55c27921 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -114,6 +114,14 @@ internal sealed record RenderShutdownRoots( CreatureAppraisalViewportRenderer? CreatureAppraisal, ChargenPreviewRenderer? ChargenPreview, ChargenPreviewController? ChargenPreviewController, + // Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance — + // same guard/shutdown shape as the Appearance-page preview above (a + // SEPARATE renderer/controller pair, not a shared one — retail's own + // gmCGSummaryPage::InitializePage @0x0047bbf0 constructs its own + // gmCG3DView, confirmed a distinct instance from the Appearance page's + // during the CC6b-MOUNT review). + ChargenPreviewRenderer? SummaryPreview, + ChargenPreviewController? SummaryPreviewController, WbDrawDispatcher? DrawDispatcher, EnvCellRenderer? EnvironmentCells, PortalDepthMaskRenderer? PortalDepthMask, @@ -493,6 +501,8 @@ internal static class GameWindowShutdownManifest () => render.CreatureAppraisal?.Dispose()), Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()), Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()), + Hard("summary preview control", () => render.SummaryPreviewController?.Dispose()), + Hard("summary preview viewport", () => render.SummaryPreview?.Dispose()), Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()), Hard("environment cells", () => render.EnvironmentCells?.Dispose()), Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()), diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index cf6b8d14..76ed43e5 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -544,6 +544,21 @@ internal sealed class CurrentGameRuntimeAdapter RuntimeGenerationToken expectedGeneration) => owner.ExecuteCharacterCreation( commands => commands.AcknowledgeRejection(expectedGeneration)); + + public RuntimeCommandResult RandomizeCharacter( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeCharacter(expectedGeneration)); + + public RuntimeCommandResult RandomizeAppearance( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeAppearance(expectedGeneration)); + + public RuntimeCommandResult RandomizeClothing( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeClothing(expectedGeneration)); } private sealed class AdapterCharacterCreationObserver( diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index ec3907be..070cd131 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -282,6 +282,26 @@ internal sealed class CharacterCreationAppearancePage : IDisposable RebuildPreview(view, snapshot); } + /// + /// Campaign CC slice CC5: ports the Appearance case of + /// gmCharGenMainUI::DoRandom @ 0x004e7d70 (case 3) — + /// m_eCurType == ECG_CHOICE_CLOTHES -> + /// CharGenState::RandomizeClothing(state, 1), else + /// CharGenState::RandomizeAppearance(state, 0). Retires the + /// Appearance half of register AP-212 (the primitives are now real, + /// faithful ports — see RuntimeCharacterCreationState's own + /// Randomize section — not a uniform-pick approximation). + /// + internal void Randomize() + { + if (_disposed) + return; + if (_currentChoice == Choice.Clothes) + _bindings.RandomizeClothing?.Invoke(); + else + _bindings.RandomizeAppearance?.Invoke(); + } + // ── Gender / Face-Clothes sub-tab ────────────────────────────────── private void SelectChoice(Choice choice) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs new file mode 100644 index 00000000..d95dfdc8 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -0,0 +1,372 @@ +using System.Globalization; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Summary page (gmCGSummaryPage, root 0x100003d6) — +/// Campaign CC slice CC5, retiring the TS-82 content-inert placeholder. +/// Decomp anchors: gmCGSummaryPage::InitializePage @ 0x0047bbf0 +/// (widget ids, its OWN gmCG3DView instance, camera set + 180° +/// heading + StartAnimation — a live idle-animated preview, not a +/// static frozen frame), ::SetSummaryText @ 0x0047b1d0 (the listbox's +/// three-template row content: template 0 = a single UiText line, +/// template 1 = a category-header UiText, template 2 = a two-column +/// key/value UiText pair — live-DAT-probe-confirmed against the +/// installed EoR dat, resolving DID 0x2100004C elements +/// 0x100002F8/FA/FB), ::ListenToElementMessage @ 0x0047bf40 +/// (the name field's commit-on-idMessage-0x12-or-0x44 dispatch, the +/// >32-char ID_CharGen_NameTooLong reject-and-revert path — see +/// 's own doc comment for the 32-vs-33 +/// reconciliation), ::DoNameLimitDialog @ 0x0047bd80. +/// +/// +/// Listbox content scope cut (register-worthy, AP-213's own precedent): +/// retail's skills section walks FOUR buckets (Specialized/Trained/ +/// UseableUntrained/UnuseableUntrained) and lists every skill name in each. +/// This port lists Specialized and Trained only — the two buckets a player +/// actually spent credits on and would review before Finishing — and skips +/// the two Untrained buckets (which would otherwise list the ~50 skills the +/// player did NOT touch, adding volume without decision-relevant +/// information). Health/Stamina/Mana reuse +/// 's own already-cited +/// UpdateAttributeValues @ 0x00482450 formulas (Health=Endurance/2, +/// Stamina=Endurance, Mana=Self) rather than this page's OWN +/// SetSummaryText call site, whose two GetAttribute calls for +/// Health/Stamina are decompiler-ambiguous (both show a literal attribute +/// index of 2 — ProfessionPage's site is the cleaner citation). +/// +/// +internal sealed class CharacterCreationSummaryPage : IDisposable +{ + internal const uint ListBoxId = 0x10000400u; + internal const uint ScrollId = 0x10000401u; + internal const uint NameTextId = 0x10000402u; + internal const uint HowToTextId = 0x10000404u; + internal const uint ViewportId = 0x10000406u; + + /// Row-template child ids, live-DAT-probe-confirmed: + /// template 0's single line, template 1's header line, template 2's + /// key/value pair. + private const uint SingleLineTextId = 0x100002F9u; + private const uint HeaderTextId = 0x100000FEu; + private const uint KeyTextId = 0x100002FCu; + private const uint ValueTextId = 0x100002FDu; + + /// Retail's name[33] buffer (32 usable chars + null + /// terminator — RuntimeCharacterCreationState.TrySetName's own + /// already-established storage cap). The decompiled UI-side check at + /// ListenToElementMessage @ 0x0047bfd1 compares the raw input + /// length against the literal 0x21 (33) — one more than this — + /// but that comparison's exact base (visible character count vs. an + /// internal length-prefix accounting the decompiler didn't resolve + /// cleanly) is not fully certain from the pseudo-C. Using 32 here keeps + /// the UI-level reject-and-revert threshold CONSISTENT with the + /// already-reviewed storage cap rather than trusting an ambiguous + /// 1-off decomp literal over that established contract. + private const int MaxNameLength = 32; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly RetailDialogFactory _dialogs; + private readonly string _nameTooLongMessage; + private readonly UiTemplateListBox? _list; + private readonly UiField? _nameField; + private string _lastCommittedName = string.Empty; + private bool _suppressNextFieldEvent; + private uint _nameTooLongDialogContext; + private bool _disposed; + + /// Late-bound preview control seam — see + /// 's own doc comment for why this + /// page cannot receive the real renderer at construction time. + internal IChargenPreviewControl? PreviewControl { get; set; } + + /// The authored viewport (0x10000406) — Summary's OWN + /// gmCG3DView instance, distinct from the Appearance page's. + internal UiViewport? Viewport { get; } + + internal CharacterCreationSummaryPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings, + RetailDialogFactory dialogs, + string nameTooLongMessage) + { + _bindings = bindings; + _dialogs = dialogs; + _nameTooLongMessage = nameTooLongMessage; + + _list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox; + + _nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField; + if (_nameField is not null) + { + // NameInputFilter @ 0x004663b0: ASCII letters, space, apostrophe, + // hyphen — everything else is rejected per keystroke. + _nameField.CharacterFilter = NameInputFilter; + // Deliberately NOT capping UiField.MaxCharacters at MaxNameLength: + // retail's own >32-char check (ListenToElementMessage's own + // GetText().m_charbuffer length compare) only fires at COMMIT + // time (idMessage 0x12/0x44), which means the textbox itself + // accepts MORE than 32 characters while typing — the + // DoNameLimitDialog reject-and-revert path exists specifically + // to catch that post-typing case. A per-keystroke cap here would + // make that whole retail code path structurally unreachable. + // ListenToElementMessage @ 0x0047bf50: the name field commits on + // idMessage 0x12 OR 0x44 — acdream's UiField exposes those two + // triggers as OnFocusLost (clicking/tabbing away) and OnSubmit + // (Enter). Both route through the same commit path. + _nameField.OnFocusLost = CommitNameFromField; + _nameField.OnSubmit = CommitNameFromField; + _nameField.ClearOnSubmit = false; + _nameField.RecordHistory = false; + } + + Viewport = UiElement.FindDescendant(pageRoot, ViewportId) as UiViewport; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_disposed) + return; + + // Keep the field's displayed text in sync with the committed name + // unless the player is actively typing (a mid-edit Refresh — driven + // by an unrelated selection change elsewhere on the screen — must + // not clobber their in-progress keystrokes). + if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name) + { + _suppressNextFieldEvent = true; + field.SetText(snapshot.Name); + _lastCommittedName = snapshot.Name; + } + + RebuildListbox(view, snapshot); + RebuildPreview(view, snapshot); + } + + // ── Name field (ListenToElementMessage @ 0x0047bf40) ──────────────── + + private void CommitNameFromField(string text) + { + if (_disposed) + return; + if (_suppressNextFieldEvent) + { + _suppressNextFieldEvent = false; + return; + } + + if (text.Length > MaxNameLength) + { + // DoNameLimitDialog @ 0x0047bd80 (ID_CharGen_NameTooLong): + // revert the field to the last COMMITTED name rather than the + // rejected input. + _nameField?.SetText(_lastCommittedName); + ShowNameTooLongDialog(); + return; + } + + _lastCommittedName = text; + _bindings.SetName?.Invoke(text); + } + + private void ShowNameTooLongDialog() + { + // DoNameLimitDialog's own guard: a context already open is a no-op. + if (_nameTooLongDialogContext != 0u) + return; + _nameTooLongDialogContext = _dialogs.MakeMessage( + _nameTooLongMessage, + data => + { + _ = data; + _nameTooLongDialogContext = 0u; + }); + } + + /// Ports NameInputFilter @ 0x004663b0 exactly: ASCII + /// letters (isalpha), space (0x20), apostrophe + /// (0x27), or hyphen (0x2d). + private static bool NameInputFilter(char c) => + (c < 0x100 && char.IsAsciiLetter(c)) || c is ' ' or '\'' or '-'; + + // ── Listbox (SetSummaryText @ 0x0047b1d0) ─────────────────────────── + + private void RebuildListbox( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_list is null || _list.Templates.Count < 3) + return; + + _list.Flush(); + + if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage)) + return; + + UiTemplateListEntry lineTemplate = _list.Templates[0]; + UiTemplateListEntry headerTemplate = _list.Templates[1]; + UiTemplateListEntry pairTemplate = _list.Templates[2]; + + AddLine(lineTemplate, "Profession: " + ProfessionName(heritage, snapshot.Template)); + AddLine(lineTemplate, "Gender: " + GenderName(heritage, snapshot.GenderKey)); + AddLine(lineTemplate, "Heritage: " + heritage.Name); + AddLine(lineTemplate, "Starting Town: " + StarterAreaName(view.Options, snapshot.StartArea)); + + AddHeader(headerTemplate, "Attributes"); + ChargenAttributeValues a = snapshot.Attributes; + AddPair(pairTemplate, "Strength", a.Strength); + AddPair(pairTemplate, "Endurance", a.Endurance); + AddPair(pairTemplate, "Coordination", a.Coordination); + AddPair(pairTemplate, "Quickness", a.Quickness); + AddPair(pairTemplate, "Focus", a.Focus); + AddPair(pairTemplate, "Self", a.Self); + // CharacterCreationProfessionPage::Refresh's own already-cited + // UpdateAttributeValues formulas (Health=Endurance/2, Stamina= + // Endurance, Mana=Self) — see this class's own doc comment on why + // that citation is used here instead of this page's own + // decompiler-ambiguous GetAttribute(2)/GetAttribute(2) pair. + AddPair(pairTemplate, "Health", a.Endurance / 2); + AddPair(pairTemplate, "Stamina", a.Endurance); + AddPair(pairTemplate, "Mana", a.Self); + AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits); + + AddSkillBucket(headerTemplate, lineTemplate, view, "Specialized Skills", ChargenSkillAdvancementClass.Specialized); + AddSkillBucket(headerTemplate, lineTemplate, view, "Trained Skills", ChargenSkillAdvancementClass.Trained); + } + + private void AddLine(UiTemplateListEntry template, string text) + { + if (ResolveTemplateChild(template, SingleLineTextId) is { } child) + SetLine(child, text); + } + + private void AddHeader(UiTemplateListEntry template, string text) + { + if (ResolveTemplateChild(template, HeaderTextId) is { } child) + SetLine(child, text); + } + + private void AddPair(UiTemplateListEntry template, string key, int value) + { + UiElement? row = ResolveTemplateRow(template); + if (row is null) + return; + if (UiElement.FindDescendant(row, KeyTextId) is UiText keyText) + SetLine(keyText, key); + if (UiElement.FindDescendant(row, ValueTextId) is UiText valueText) + SetLine(valueText, value.ToString(CultureInfo.InvariantCulture)); + } + + private static void SetLine(UiText text, string content) => + text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)]; + + private UiElement? ResolveTemplateRow(UiTemplateListEntry template) + { + if (_list is null || _list.TemplateResolver is null) + return null; + UiElement? row = _list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId); + if (row is null) + return null; + _list.AddPrebuiltRow(row); + return row; + } + + private UiText? ResolveTemplateChild(UiTemplateListEntry template, uint childId) + { + UiElement? row = ResolveTemplateRow(template); + return row is null ? null : UiElement.FindDescendant(row, childId) as UiText; + } + + private void AddSkillBucket( + UiTemplateListEntry headerTemplate, + UiTemplateListEntry lineTemplate, + IRuntimeCharacterCreationView view, + string header, + ChargenSkillAdvancementClass targetClass) + { + bool any = false; + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + if (view.GetSkillLevel(skillId) != targetClass) + continue; + if (!any) + { + AddHeader(headerTemplate, header); + any = true; + } + AddLine(lineTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId)); + } + } + + private static string ProfessionName(ChargenHeritageOptions heritage, uint template) => + template != RuntimeCharacterCreationSnapshot.TemplateUnset + && template < (uint)heritage.Templates.Count + ? heritage.Templates[(int)template].Name + : "None"; + + private static string GenderName(ChargenHeritageOptions heritage, uint genderKey) => + heritage.GendersByKey.TryGetValue((int)genderKey, out ChargenGenderOptions? gender) + ? gender.Name + : "None"; + + private static string StarterAreaName(ChargenOptions options, int startArea) => + startArea >= 0 && startArea < options.StarterAreas.Count + ? options.StarterAreas[startArea].Name + : "None"; + + // ── Preview (own gmCG3DView — InitializePage @0x0047bbf0, camera set + + // ── SetPlayerHeading(180) + StartAnimation, an idle-animated view) ─── + + private void RebuildPreview( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (PreviewControl is null + || snapshot.HeritageId == 0u + || snapshot.GenderKey == 0u) + { + return; + } + + RuntimeCharacterCreationAppearance a = snapshot.Appearance; + var selection = new ChargenAppearanceSelection( + a.EyesStrip, a.NoseStrip, a.MouthStrip, + a.HairStyle, a.HairColor, a.EyeColor, + a.HeadgearStyle, a.HeadgearColor, + a.ShirtStyle, a.ShirtColor, + a.TrousersStyle, a.TrousersColor, + a.FootwearStyle, a.FootwearColor, + a.SkinShade, a.HairShade, a.HeadgearShade, + a.ShirtShade, a.TrousersShade, a.FootwearShade); + + PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_nameField is not null) + { + _nameField.OnFocusLost = null; + _nameField.OnSubmit = null; + } + if (_nameTooLongDialogContext != 0u) + { + uint closing = _nameTooLongDialogContext; + _nameTooLongDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + _list?.Flush(); + // PreviewControl is owned by the composition root (disposed with + // the leased ChargenPreviewRenderer) — just drop the reference. + PreviewControl = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index 36126958..d74a0462 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -43,6 +44,23 @@ public sealed record CharacterCreationRuntimeBindings( /// degrades to the heritage's own DAT /// Name field instead of the full composed copy. Func? ResolveText = null, + /// Campaign CC slice CC5: the Summary page's name field + /// commit (gmCGSummaryPage::ListenToElementMessage's + /// CharGenState::SetName call). + Func? SetName = null, + /// CC5: dismisses a surfaced 0xF643 rejection after its + /// dialog closes (RuntimeCharacterCreationState.TryAcknowledgeRejection). + Func? AcknowledgeRejection = null, + /// CC5: the screen-open roll + /// (gmCharGenMainUI's ctor-time RandomizeCharacter call) + /// and the Summary page's Random button. + Func? RandomizeCharacter = null, + /// CC5: the Appearance page's Random button on its Face + /// sub-tab. + Func? RandomizeAppearance = null, + /// CC5: the Appearance page's Random button on its Clothes + /// sub-tab. + Func? RandomizeClothing = null, bool OpenOnStart = false); /// @@ -106,7 +124,23 @@ internal sealed class CharacterCreationUiController : IDisposable Summary = 6, } - internal sealed record DialogStrings(string ExitWarning); + internal sealed record DialogStrings( + string ExitWarning, + /// Campaign CC slice CC5: ID_CharGen_NoNameWarning — + /// DoFinish's empty-name refusal dialog. + string NoNameWarning, + /// CC5: ID_CharGen_CreditWarning — + /// MakeCreditWarningDialog's unspent-attribute-credits + /// confirmation. + string CreditWarning, + /// CC5: ID_CharGen_RandomizeWarning — + /// MakeRandomizeWarningDialog's Summary-page Random + /// confirmation. + string RandomizeWarning, + /// CC5: ID_CharGen_NameTooLong — + /// gmCGSummaryPage::DoNameLimitDialog's name-field-too-long + /// notice. + string NameTooLong); private readonly UiRoot _host; private readonly ImportedLayout _layout; @@ -138,6 +172,7 @@ internal sealed class CharacterCreationUiController : IDisposable private readonly CharacterCreationSkillsPage _skillsPage; private readonly CharacterCreationTownPage _townPage; private readonly CharacterCreationAppearancePage _appearancePage; + private readonly CharacterCreationSummaryPage _summaryPage; private Vector2 _authoredCanvas; private RuntimeGenerationToken _lastGeneration; @@ -147,6 +182,13 @@ internal sealed class CharacterCreationUiController : IDisposable private bool _isOpen; private bool _openOnStartConsumed; private uint _exitDialogContext; + // Campaign CC slice CC5: gmCharGenMainUI's own m_uiCreditWarningContext/ + // m_uiRandomizeWarningContext (0x004e8870/0x004e8a90) — same + // one-outstanding-dialog-at-a-time guard shape as _exitDialogContext. + private uint _creditWarningDialogContext; + private uint _randomizeWarningDialogContext; + private uint _noNameWarningDialogContext; + private RuntimeCharacterCreationRejection? _lastShownRejection; private bool _suppressDialogCallbacks; private bool _disposed; @@ -227,18 +269,18 @@ internal sealed class CharacterCreationUiController : IDisposable _skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver); _townPage = new CharacterCreationTownPage(townPageRoot, bindings); _appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings); + _summaryPage = new CharacterCreationSummaryPage( + summaryPageRoot, bindings, dialogs, strings.NameTooLong); // gmCharGenMainUI::ListenToElementMessage @ 0x004e9450. _back.OnClick = OnBack; _next.OnClick = OnNext; - // Finish (0x100003c8) stays ghosted this round: Summary - // (0x100003d6) is CC5's placeholder, and DoFinish's real gate - // sequence lives in RuntimeCharacterCreationState.TryBeginFinish — - // wiring the button here without a Summary page to confirm/collect - // the name would let a click reach the wire with an empty name and - // silently refuse. No OnClick handler; _finish.Enabled stays false - // (see ApplyProgressState). - _finish.OnClick = null; + // Finish (0x100003c8): retail enables it on Summary only + // (ListenToElementMessage's case 0x100003c8 no-ops unless + // m_eProgressState == ECG_SUMMARY @ 0x004e956f) — ApplyProgressState + // gates _finish.Enabled the same way. OnFinish itself re-checks the + // current page defensively (mirroring that same retail guard). + _finish.OnClick = OnFinish; // Help (0x100003c9) is not handled in gmCharGenMainUI's own // ListenToElementMessage switch (case 0x100003c9 falls straight // through to the base UIFramework handler) — retail has no custom @@ -280,6 +322,26 @@ internal sealed class CharacterCreationUiController : IDisposable /// paperdoll's own outer-inventory-frame gate. internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible; + /// Campaign CC slice CC5: the authored Summary-page viewport + /// (0x10000406) — its OWN gmCG3DView instance, distinct + /// from the Appearance page's (see this class's own class doc on the + /// decomp citation). + internal UiViewport? SummaryViewport => _summaryPage.Viewport; + + /// CC5: the Summary preview's late-bound control surface. No + /// zoom/rotate buttons bind against it — see + /// 's + /// doc comment. + internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl + { + get => _summaryPage.PreviewControl; + set => _summaryPage.PreviewControl = value; + } + + /// CC5: same shape as , + /// for the Summary page. + internal bool IsSummaryPageVisible => Root.Visible && _summaryPageRoot.Visible; + internal static CharacterCreationUiController? CreateDetached( UiRoot host, ImportedLayout layout, @@ -404,6 +466,7 @@ internal sealed class CharacterCreationUiController : IDisposable _skillsPage.Refresh(view, snapshot); _townPage.Refresh(view, snapshot); _appearancePage.Refresh(view, snapshot); + _summaryPage.Refresh(view, snapshot); _lastGeneration = snapshot.Generation; _lastRevision = snapshot.Revision; } @@ -426,9 +489,40 @@ internal sealed class CharacterCreationUiController : IDisposable return; _isOpen = true; _host.DeclareFixedCanvas(this, _authoredCanvas); + RollOpeningCharacter(); ApplyProgressState(Page.Heritage); } + /// + /// Campaign CC slice CC5: ports gmCharGenMainUI's ctor-time roll + /// (~0x004e81f5-0x004e8218) — CharGenState::RandomizeCharacter + /// (state, hasToD) @ 0x005c6d80 runs BEFORE any page constructs, + /// retiring AP-214's honest-blank deviation (retail's chargen screen is + /// never actually blank on open). Then reproduces + /// gmCGAppearancePage::InitializePage's own gender-read-then-FLIP + /// (~0x004802da-0x00480303, decomp-confirmed: + /// mGender==1 -> SetGender(2), mGender==2 -> SetGender(1)) — + /// a genuine, always-firing retail quirk that runs immediately AFTER + /// RandomizeCharacter already assigned a real (non-zero) gender. + /// Retail's whole UI tree (every page, including Appearance) is + /// reconstructed fresh each time the chargen screen opens, so the flip + /// fires once per visit there; acdream's pages are built once at mount + /// time and only toggle visibility, so — the closest + /// analogue to "runs once per screen-open" this architecture has — is + /// where both the roll and the flip belong. + /// + private void RollOpeningCharacter() + { + if (_bindings.RandomizeCharacter?.Invoke().Status != RuntimeCommandStatus.Accepted) + return; + + uint gender = _bindings.View()?.Snapshot.GenderKey ?? 0u; + if (gender == 1u) + _bindings.SelectGender(2u); + else if (gender == 2u) + _bindings.SelectGender(1u); + } + private void Close() { if (!_isOpen) @@ -472,6 +566,7 @@ internal sealed class CharacterCreationUiController : IDisposable _skillsPage.Dispose(); _townPage.Dispose(); _appearancePage.Dispose(); + _summaryPage.Dispose(); _host.RemoveChild(Root); } } @@ -535,10 +630,16 @@ internal sealed class CharacterCreationUiController : IDisposable return; // gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town - // are ported below; Skills' CharGenState::RandomizeSkills and the - // Summary randomize-warning dialog have no CC3 primitive/page yet - // this round — register AP-212 covers both gaps, and _random.Enabled - // already keeps the control ghosted on those pages (ApplyProgressState). + // still use the AP-212 uniform-pick approximation (unchanged this + // slice); Appearance now delegates to the page's own real + // RandomizeAppearance/RandomizeClothing primitives (CC5); Skills' + // CharGenState::RandomizeSkills remains unported (AP-212, narrowed) — + // _random.Enabled already keeps the control ghosted there + // (ApplyProgressState). Summary goes through + // gmCharGenMainUI::MakeRandomizeWarningDialog @ 0x004e8a90 first — + // that dialog + its confirm-triggered RandomizeCharacter call are + // gmCharGenMainUI's OWN methods in retail (not gmCGSummaryPage's), + // so they live here on the master controller. IRuntimeCharacterCreationView? view = _bindings.View(); if (view is null) return; @@ -552,12 +653,44 @@ internal sealed class CharacterCreationUiController : IDisposable case Page.Profession: _professionPage.Randomize(snapshot); break; + case Page.Appearance: + _appearancePage.Randomize(); + break; case Page.Town: _townPage.Randomize(view); break; + case Page.Summary: + ShowRandomizeWarningDialog(); + break; } } + /// Ports gmCharGenMainUI::MakeRandomizeWarningDialog @ + /// 0x004e8a90 (ID_CharGen_RandomizeWarning) + + /// CloseRandomizeWarningDialog @ 0x004e8400's own confirm arm + /// (arg2 != 0 -> DoRandom(this), which on THIS second call + /// takes DoRandom's Summary case directly — no re-entrant + /// warning, since the gate lives in the button-click dispatcher above, + /// not inside DoRandom itself). + private void ShowRandomizeWarningDialog() + { + // MakeRandomizeWarningDialog's own guard: a second click while the + // dialog is already open is a no-op. + if (_randomizeWarningDialogContext != 0u) + return; + + _randomizeWarningDialogContext = _dialogs.MakeConfirmation( + _strings.RandomizeWarning, + data => + { + _randomizeWarningDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + _bindings.RandomizeCharacter?.Invoke(); + }); + } + // ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ──── private void ApplyProgressState(Page target) @@ -641,19 +774,14 @@ internal sealed class CharacterCreationUiController : IDisposable break; } - // 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 - // placeholder this round (see the ctor comment on _finish.OnClick). - _finish.Enabled = false; + // Random (0x100003cb): CC5 ports RandomizeAppearance/RandomizeClothing + // (Appearance) and RandomizeCharacter (Summary), retiring both gaps + // AP-212 used to track for those two pages — only Skills' + // RandomizeSkills remains unported (AP-212, narrowed). + _random.Enabled = _currentPage is not Page.Skills; + // Finish (0x100003c8): retail enables it on Summary only + // (ListenToElementMessage's case 0x100003c8 no-ops off Summary). + _finish.Enabled = _currentPage == Page.Summary; _lastRevision = long.MinValue; Tick(); @@ -719,12 +847,130 @@ internal sealed class CharacterCreationUiController : IDisposable // Else (including Lugian, 0x100005f1): no-op, matching retail. } + // ── Finish (gmCharGenMainUI::DoFinish @ 0x004E9170) ───────────────── + + /// The Finish button's ordinary click — retail's arg2 = 1 + /// call site (0x004E9579). Re-checks the current page defensively, + /// mirroring ListenToElementMessage's own + /// m_eProgressState != ECG_SUMMARY no-op guard. + private void OnFinish() + { + if (_disposed || _currentPage != Page.Summary) + return; + TryFinish(confirmedUnspentCredits: false); + } + + /// + /// Sends via + /// (which itself calls RuntimeCharacterCreationState.TryBeginFinish); + /// on a LOCAL refusal, surfaces retail's own dialog for the two refusal + /// reasons retail dialogs at all (NoName -> + /// ID_CharGen_NoNameWarning; AttributeCreditsUnspent -> + /// the credit-warning confirm, whose OWN confirm re-invokes this method + /// with — retail's + /// arg2 == 0 call site, 0x004E98BB). The remaining local + /// refusals (HeritageOrGenderUnset, AlreadyPending, + /// RosterFull) have no retail dialog citation — retail's own + /// DoFinish silently falls through to its final return 0 + /// for an already-Pending double-click, and the other two are + /// acdream-only additions (register AP-223, AP-211) with the same + /// silent-refusal shape. + /// + private void TryFinish(bool confirmedUnspentCredits) + { + if (_bindings.Finish(confirmedUnspentCredits).Status != RuntimeCommandStatus.Rejected) + return; + + RuntimeCharacterCreationLocalRefusal refusal = + _bindings.View()?.Snapshot.LastLocalRefusal ?? default; + if (refusal.NoName) + ShowNoNameWarningDialog(); + else if (refusal.AttributeCreditsUnspent) + ShowCreditWarningDialog(); + } + + /// Ports the empty-name half of DoFinish + /// (ID_CharGen_NoNameWarning, @0x004e91dd) — a plain + /// informational dialog, no confirm/cancel semantics. + private void ShowNoNameWarningDialog() + { + if (_noNameWarningDialogContext != 0u) + return; + _noNameWarningDialogContext = _dialogs.MakeMessage( + _strings.NoNameWarning, + data => + { + _ = data; + _noNameWarningDialogContext = 0u; + }); + } + + /// Ports gmCharGenMainUI::MakeCreditWarningDialog @ + /// 0x004e8870 (ID_CharGen_CreditWarning) — on confirm, + /// re-invokes with + /// confirmedUnspentCredits: true, retail's DoFinish(this, 0) + /// call at RecvNotice_CloseDialog @0x004e98bb. + private void ShowCreditWarningDialog() + { + if (_creditWarningDialogContext != 0u) + return; + _creditWarningDialogContext = _dialogs.MakeConfirmation( + _strings.CreditWarning, + data => + { + _creditWarningDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + TryFinish(confirmedUnspentCredits: true); + }); + } + + // ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ── + // ── 0x0055E8B0) ────────────────────────────────────────────────────── + + /// Ports the four rejection-dialog mappings from + /// Handle_CharGenVerificationResponse's per-case switch (restated + /// on 's own doc + /// comment); Pending/Undef never reach this method (CC3's + /// ApplyCreationResponse treats them as a silent state reset with + /// no produced at all). + /// Dedups against the LAST rejection instance already shown so a + /// same-value re-check on a later (this method runs + /// every tick, not just on revision change) doesn't reopen the dialog + /// the player already dismissed. private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot) { - // Local-refusal / rejection surfacing is CC5's Summary-page job - // (the Finish gate only fires from that page). This round only - // needs the exit-confirmation dialog reconciled against disposal. - _ = snapshot; + RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection; + if (rejection is null) + { + _lastShownRejection = null; + return; + } + if (_lastShownRejection == rejection) + return; + _lastShownRejection = rejection; + + string? key = rejection.Value.Code switch + { + CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved", + CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned", + CharGenVerificationResponse.Code.Corrupt + or CharGenVerificationResponse.Code.DatabaseDown => "ID_Character_Err_NameDBDown", + CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied", + _ => null, + }; + if (key is null) + return; + string? message = _bindings.ResolveText?.Invoke(key); + if (message is null) + return; + + _dialogs.MakeMessage(message, data => + { + _ = data; + _bindings.AcknowledgeRejection?.Invoke(); + }); } private void Deactivate() @@ -750,6 +996,24 @@ internal sealed class CharacterCreationUiController : IDisposable _exitDialogContext = 0u; _dialogs.CloseDialog(closing); } + if (_creditWarningDialogContext != 0u) + { + uint closing = _creditWarningDialogContext; + _creditWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_randomizeWarningDialogContext != 0u) + { + uint closing = _randomizeWarningDialogContext; + _randomizeWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_noNameWarningDialogContext != 0u) + { + uint closing = _noNameWarningDialogContext; + _noNameWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } } finally { diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index dfaab47c..22f7a199 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -669,6 +669,34 @@ public sealed class RetailUiRuntime : IDisposable internal bool IsChargenPreviewPageVisible => CharacterCreationController?.IsAppearancePageVisible ?? false; + /// Campaign CC slice CC5: the Summary page's OWN authored + /// viewport (0x10000406) — same one-shot GPU-composition + /// disposition as (see that + /// property's own doc comment; AP-221 covers both). + internal UiViewport? SummaryPreviewViewportWidget => + CharacterCreationController?.SummaryViewport; + + /// Campaign CC slice CC5: the Summary preview's late-bound + /// control surface. No zoom/rotate buttons bind against it (retail's + /// Summary page has none) — the composition root assigns it purely so + /// + /// gets driven per-selection-change the same way the Appearance + /// preview's is. + internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl + { + get => CharacterCreationController?.SummaryPreviewControl; + set + { + if (CharacterCreationController is { } controller) + controller.SummaryPreviewControl = value; + } + } + + /// Campaign CC slice CC5: whether the Summary page + /// (specifically) is the one currently showing. + internal bool IsSummaryPreviewPageVisible => + CharacterCreationController?.IsSummaryPageVisible ?? false; + public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { ArgumentNullException.ThrowIfNull(bindings); @@ -3977,14 +4005,38 @@ public sealed class RetailUiRuntime : IDisposable } string? exitWarning; + string? noNameWarning; + string? creditWarning; + string? randomizeWarning; + string? nameTooLong; lock (_bindings.Assets.DatLock) { exitWarning = ResolveCharacterManagementString( strings, stringTableId, "ID_CharGen_ExitWarning"); + noNameWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_NoNameWarning"); + creditWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_CreditWarning"); + randomizeWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_RandomizeWarning"); + nameTooLong = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_NameTooLong"); } - if (exitWarning is null) + if (exitWarning is null + || noNameWarning is null + || creditWarning is null + || randomizeWarning is null + || nameTooLong is null) { Console.WriteLine( "[UI] character creation: required retail strings are unavailable."); @@ -4009,7 +4061,8 @@ public sealed class RetailUiRuntime : IDisposable layoutId, layout, ResolveTemplate, - new CharacterCreationUiController.DialogStrings(exitWarning)); + new CharacterCreationUiController.DialogStrings( + exitWarning, noNameWarning, creditWarning, randomizeWarning, nameTooLong)); } private void MountItemCooldowns() diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index 1b34e5b8..cef6e78d 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -460,6 +460,30 @@ public interface IRuntimeCharacterCreationCommands RuntimeCommandResult AcknowledgeRejection( RuntimeGenerationToken expectedGeneration); + + // ── Campaign CC slice CC5: RandomizeCharacter port ────────────────── + + /// Retail's ctor-time CharGenState::RandomizeCharacter + /// roll (mirrored at the App layer's screen-open edge) and the Summary + /// page's Random button (gmCharGenMainUI::DoRandom case 5, behind + /// the caller's own ID_CharGen_RandomizeWarning confirmation) — + /// see . + RuntimeCommandResult RandomizeCharacter( + RuntimeGenerationToken expectedGeneration); + + /// The Appearance page's Random button while its Face sub-tab + /// is showing (gmCharGenMainUI::DoRandom case 3's else + /// arm) — see + /// . + RuntimeCommandResult RandomizeAppearance( + RuntimeGenerationToken expectedGeneration); + + /// The Appearance page's Random button while its Clothes + /// sub-tab is showing (gmCharGenMainUI::DoRandom case 3's + /// RandomizeClothing(state, 1) arm) — see + /// . + RuntimeCommandResult RandomizeClothing( + RuntimeGenerationToken expectedGeneration); } public interface IGameRuntimeCommands diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 244c6a7f..12a49065 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -1657,6 +1657,45 @@ public sealed class LiveSessionController } } + public RuntimeCommandResult RandomizeCharacter( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TryRandomizeCharacter()); + } + } + + public RuntimeCommandResult RandomizeAppearance( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TryRandomizeAppearance()); + } + } + + public RuntimeCommandResult RandomizeClothing( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TryRandomizeClothing()); + } + } + private RuntimeCommandStatus ValidateCharacterCreationCommand( RuntimeGenerationToken expectedGeneration) { diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index 05a79951..701a3bf8 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -124,16 +124,32 @@ public readonly record struct RuntimeCharacterCreationAppearance( /// evaluates before a Finish click is allowed to reach the wire, plus the /// campaign's client-side slot cap (risk item 3 — retail's UI, not /// DoFinish itself, refuses when the roster is already full versus -/// CharacterList.slotCount; ACE never checks this server-side). +/// CharacterList.slotCount; ACE never checks this server-side), plus +/// (Campaign CC slice CC5, the CC6b-MOUNT review's F12 amendment) +/// — an acdream-ONLY addition with no +/// direct DoFinish citation (register AP-223): retail's own +/// DoFinish never checks heritage/gender because it can't reach a +/// state where either is unset — gmCharGenMainUI's constructor calls +/// CharGenState::RandomizeCharacter before any page (including +/// Summary/Finish) exists, so a real heritage+gender selection is an +/// ARCHITECTURAL guarantee by the time Finish is clickable at all. Once +/// is +/// wired at the App layer's screen-open edge (mirroring that same ctor +/// call), this refusal is normally unreachable through the ordinary UI — +/// it exists as a defensive backstop for any caller (a headless bot, a +/// future direct command) that can reach Finish without that +/// open-edge roll ever having run. /// public readonly record struct RuntimeCharacterCreationLocalRefusal( bool NoName, bool AttributeCreditsUnspent, bool AlreadyPending, - bool RosterFull) + bool RosterFull, + bool HeritageOrGenderUnset = false) { public bool Any => - NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull; + NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull + || HeritageOrGenderUnset; public static RuntimeCharacterCreationLocalRefusal None { get; } = default; } @@ -487,27 +503,38 @@ public sealed class RuntimeCharacterCreationState : IDisposable if (_disposed || !_active) return false; - _heritageId = heritageId; - _totalAttributeCredits = heritage.AttributeCredits; - _totalSkillCredits = heritage.SkillCredits; - _remainingSkillCredits = checked((int)heritage.SkillCredits); - - ApplyTemplateLocked(heritage); - RandomizeStartAreaLocked(heritage); - ConstrainAppearanceByGenderLocked(); - RecomputeRemainingAttributeCreditsLocked(); - // ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive - // re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through - // our own gated skill commands, but kept for parity with a - // heritage switch that leaves stale skill picks over-budget. - if (RecomputeSkillSpendLocked(heritage) < 0) - ResetSkillLevelsLocked(heritage); + SetHeritageGroupLocked(heritageId, heritage); _revision++; } Publish(RuntimeCharacterCreationDeltaKind.StateChanged); return true; } + /// + /// The locked body of CharGenState::SetHeritageGroup @ 0x005C67A0 — + /// factored out of (Campaign CC slice + /// CC5) so 's own heritage roll + /// can reuse it without re-entering . + /// + private void SetHeritageGroupLocked(uint heritageId, ChargenHeritageOptions heritage) + { + _heritageId = heritageId; + _totalAttributeCredits = heritage.AttributeCredits; + _totalSkillCredits = heritage.SkillCredits; + _remainingSkillCredits = checked((int)heritage.SkillCredits); + + ApplyTemplateLocked(heritage); + RandomizeStartAreaLocked(heritage); + ConstrainAppearanceByGenderLocked(); + RecomputeRemainingAttributeCreditsLocked(); + // ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive + // re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through + // our own gated skill commands, but kept for parity with a + // heritage switch that leaves stale skill picks over-budget. + if (RecomputeSkillSpendLocked(heritage) < 0) + ResetSkillLevelsLocked(heritage); + } + /// Ports CharGenState::SetGender @ 0x005C64A0: clamps /// every appearance index into the new gender's option-list bounds. The /// four SetXStyle(this, this->XStyle) re-invocations retail @@ -527,14 +554,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable return false; } - _genderKey = genderKey; - ConstrainAppearanceByGenderLocked(); + SetGenderLocked(genderKey); _revision++; } Publish(RuntimeCharacterCreationDeltaKind.StateChanged); return true; } + /// The locked body of CharGenState::SetGender @ + /// 0x005C64A0 — factored out of + /// (Campaign CC slice CC5) so 's + /// own gender roll can reuse it. + private void SetGenderLocked(uint genderKey) + { + _genderKey = genderKey; + ConstrainAppearanceByGenderLocked(); + } + /// /// Ports the seven Profession-page buttons, each of which calls /// CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60 — template @@ -1164,6 +1200,362 @@ public sealed class RuntimeCharacterCreationState : IDisposable : value; } + // ── Randomize (Campaign CC slice CC5) ─────────────────────────────── + // Ports CharGenState::RandomizeCharacter @ 0x005c6d80 and its six named + // sub-primitives (RandomizeHeritageGroup/RandomizeAppearance/ + // RandomizeHeadgear/RandomizeShirt/RandomizeTrousers/RandomizeFootwear/ + // RandomizeTemplate/RandomizeStartArea — register AP-212's own citation + // list). The RNG primitive both retail's own RandInt(int) and + // RandInt(int,int) overloads reduce to is decompiled verbatim at + // 0x00684400/0x00684420: RandInt(count) is a uniform pick in [0,count); + // RandInt(count,exclude) loops the same roll until it differs from + // exclude (a no-op when count<=1, matching retail's own early-return — + // ported as RandomizeIndexExcludingLocked below). CharGenState's own + // vtable (acclient.h's $A0F97670E669114D706A75D718F5A366 union — + // "GetRandomInt(this,int,int)"/"Grandom Int(this,int)") confirms + // RandomizeAppearance's vtable-indirected calls are this SAME RandInt + // pair, not a distinct algorithm. + + /// Ports Random::RollDice(int,int) @ 0x0042c5c0: returns + /// unchanged when the two bounds are equal + /// (matching retail's own arg2==arg1 fast path), otherwise a + /// uniform pick over the INCLUSIVE range + /// [min(min,max), max(min,max)]. + private int RollDiceLocked(int min, int max) + { + if (min == max) + return min; + int lo = Math.Min(min, max); + int hi = Math.Max(min, max); + return lo + _random.Next(hi - lo + 1); + } + + /// Ports RandInt(int,int) @ 0x00684420 exactly: for + /// <= 1 there is only one possible outcome, + /// so retail returns 0 immediately WITHOUT ever comparing against + /// (the guard that keeps the do/while loop + /// below from spinning forever); otherwise re-rolls uniformly in + /// [0,count) until the result differs from + /// — an outside + /// [0,count) (e.g. + /// on a freshly-'d field) can never match, + /// so the loop always exits on its first iteration and this degrades to + /// a plain uniform pick. + private uint RandomizeIndexExcludingLocked(int count, uint exclude) + { + if (count <= 1) + return 0u; + uint result; + do + { + result = (uint)_random.Next(count); + } while (result == exclude); + return result; + } + + /// Ports CharGenState::RandomizeAppearance(this, 0) @ + /// 0x005c4f10 — every real call site in the retail binary passes + /// arg2 == 0 (an exhaustive grep of every RandomizeAppearance + /// call found none with arg2 != 0), so the arg2 != 0 arm + /// (a hard-coded vtable-index-7 hair-style pick) is decompiled but dead + /// code and is not ported. Each field is only rolled when its list is + /// non-empty (retail's own per-field if (count != 0) guards); + /// skinShade/hairShade are vtable->GetRandomReal() + /// — the SAME rand()*(1/32768) uniform-[0,1) shade roll every + /// other Randomize* function below uses explicitly inline. + private void RandomizeAppearanceLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + + RuntimeCharacterCreationAppearance a = _appearance; + if (gender.EyeStrips.Count > 0) + a = a with { EyesStrip = RandomizeIndexExcludingLocked(gender.EyeStrips.Count, a.EyesStrip) }; + if (gender.NoseStrips.Count > 0) + a = a with { NoseStrip = RandomizeIndexExcludingLocked(gender.NoseStrips.Count, a.NoseStrip) }; + if (gender.MouthStrips.Count > 0) + a = a with { MouthStrip = RandomizeIndexExcludingLocked(gender.MouthStrips.Count, a.MouthStrip) }; + a = a with { SkinShade = _random.NextDouble(), HairShade = _random.NextDouble() }; + if (gender.HairColors.Count > 0) + a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) }; + if (gender.EyeColors.Count > 0) + a = a with { EyeColor = RandomizeIndexExcludingLocked(gender.EyeColors.Count, a.EyeColor) }; + if (gender.HairStyles.Count > 0) + a = a with { HairStyle = RandomizeIndexExcludingLocked(gender.HairStyles.Count, a.HairStyle) }; + _appearance = a; + } + + /// + /// Ports CharGenState::RandomizeHeadgear(this, arg2) @ 0x005c5e10. + /// Headgear alone gets the count+1-position Unset ring + /// (CharacterCreationAppearancePage.CycleIndex's own already-cited + /// sibling finding): false (every + /// RandomizeCharacter call site, arg2==0) rolls a plain + /// uniform RandInt(count+1); true (RandomizeClothing(state,1)'s + /// own Appearance-page Random-button case) excludes the current style + /// via RandInt(count+1, headgearStyle+1) — the +1 + /// reindexes Unset (retail's signed -1) to 0 so the + /// exclude comparison stays in [0,count]. Color uses the SAME + /// shared-ClothingColors + /// approximation (register AP-208) every other clothing slot's color + /// count already uses, not retail's own per-heritage + /// numHeadgearColors field acdream's model does not carry. + /// + private void RandomizeHeadgearLocked(bool excludeCurrent) + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + + int styleCount = gender.Headgears.Count; + if (styleCount > 0) + { + uint current = _appearance.HeadgearStyle; + int currentPlusOne = current == RuntimeCharacterCreationAppearance.Unset + ? 0 + : (int)current + 1; + int rolled = excludeCurrent + ? (int)RandomizeIndexExcludingLocked(styleCount + 1, (uint)currentPlusOne) + : _random.Next(styleCount + 1); + uint newStyle = rolled == 0 ? RuntimeCharacterCreationAppearance.Unset : (uint)(rolled - 1); + _appearance = _appearance with { HeadgearStyle = newStyle }; + } + + int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.HeadgearColor, gender); + if (colorCount > 0) + { + _appearance = _appearance with + { + HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor), + }; + } + _appearance = _appearance with { HeadgearShade = _random.NextDouble() }; + } + + /// Ports CharGenState::RandomizeShirt @ 0x005c5ef0 — + /// unlike headgear, retail's shirt/trousers/footwear randomizers take no + /// arg2 and always exclude the current style/color. + private void RandomizeShirtLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + int styleCount = gender.Shirts.Count; + if (styleCount > 0) + { + _appearance = _appearance with + { + ShirtStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.ShirtStyle), + }; + } + int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.ShirtColor, gender); + if (colorCount > 0) + { + _appearance = _appearance with + { + ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor), + }; + } + _appearance = _appearance with { ShirtShade = _random.NextDouble() }; + } + + /// Ports CharGenState::RandomizeTrousers @ 0x005c5fb0. + private void RandomizeTrousersLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + int styleCount = gender.Pants.Count; + if (styleCount > 0) + { + _appearance = _appearance with + { + TrousersStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.TrousersStyle), + }; + } + int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.TrousersColor, gender); + if (colorCount > 0) + { + _appearance = _appearance with + { + TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor), + }; + } + _appearance = _appearance with { TrousersShade = _random.NextDouble() }; + } + + /// Ports CharGenState::RandomizeFootwear @ 0x005c6070. + private void RandomizeFootwearLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + int styleCount = gender.Footwear.Count; + if (styleCount > 0) + { + _appearance = _appearance with + { + FootwearStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.FootwearStyle), + }; + } + int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.FootwearColor, gender); + if (colorCount > 0) + { + _appearance = _appearance with + { + FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor), + }; + } + _appearance = _appearance with { FootwearShade = _random.NextDouble() }; + } + + /// Ports CharGenState::RandomizeClothing(this, arg2) @ + /// 0x005c6770: headgear (with + /// forwarded), then shirt/trousers/footwear (always exclude-current, + /// they take no arg2). + private void RandomizeClothingLocked(bool excludeCurrent) + { + RandomizeHeadgearLocked(excludeCurrent); + RandomizeShirtLocked(); + RandomizeTrousersLocked(); + RandomizeFootwearLocked(); + } + + /// + /// Ports CharGenState::RandomizeTemplate @ 0x005c6500. The two + /// Olthoi heritages force template 0 unconditionally + /// (this->template_ = 1; ApplyTemplate(this); in retail — but + /// ApplyTemplate's own Olthoi branch immediately re-forces + /// template_ = 0 regardless, so the intermediate write to 1 is + /// observably a no-op; this port skips straight to + /// , which already carries that force). + /// Otherwise, when the heritage has more than one template (Custom plus + /// at least one preset), picks RandInt(count-1, template_-1) + 1 — + /// a uniform pick over indices [1, count-1] (retail's own + /// preset templates, NEVER index 0/Custom) excluding the CURRENT + /// template (offset by -1 to align with the shifted range; an + /// Unset/0xFFFFFFFF current value wraps far outside [0,count-1) + /// and can never match, so a fresh roll off a Reset state is + /// unconstrained). + /// + private void RandomizeTemplateLocked() + { + if (_heritageId == 0 || _genderKey == 0) + return; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage)) + return; + + if (_heritageId == (uint)ChargenHeritageGroup.Olthoi + || _heritageId == (uint)ChargenHeritageGroup.OlthoiAcid) + { + ApplyTemplateLocked(heritage); + return; + } + + int count = heritage.Templates.Count; + if (count <= 1) + return; + + uint excludeShifted = unchecked(_template - 1u); + uint picked = RandomizeIndexExcludingLocked(count - 1, excludeShifted) + 1u; + _template = picked; + ApplyTemplateLocked(heritage); + } + + /// + /// Ports CharGenState::RandomizeCharacter(this, hasToD) @ + /// 0x005c6d80: (retail's own + /// Reset()), roll a heritage + /// (SetHeritageGroup(RollDice(1, hasToD?4:3)) — heritage ids + /// 1..3/4 are the four HUMAN heritage groups (Aluvian/Gharu'ndim/Sho/ + /// Viamontian, 's own numbering); a + /// "random" character in retail is deliberately always human, never one + /// of the other nine heritages — a genuine retail quirk, not a porting + /// shortcut), roll a gender (SetGender(RollDice(1,2))), then + /// appearance/headgear/shirt/trousers/footwear/template/start-area, in + /// that exact order. acdream has no account/DLC-ownership signal (AD-102's + /// already-established convention: every installed heritage/town ships + /// unconditionally selectable, matching what a ToD-owning account would + /// see) — this reuses that SAME convention rather than inventing a + /// second one, so the heritage roll always uses the 4-heritage bound. + /// SetHeritageGroupLocked already rolls a starting area once as + /// part of its own RandomizeStartAreaLocked call (mirroring + /// retail's own SetHeritageGroup); the explicit + /// RandomizeStartAreaLocked call at the end re-rolls it a SECOND + /// time, matching retail's own redundant double-roll exactly (harmless — + /// each roll is independently uniform over the same list). + /// + private void RandomizeCharacterLocked() + { + ClearSessionState(); + + uint heritageId = (uint)RollDiceLocked(1, 4); + if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + SetHeritageGroupLocked(heritageId, heritage); + + uint genderKey = (uint)RollDiceLocked(1, 2); + SetGenderLocked(genderKey); + + RandomizeAppearanceLocked(); + RandomizeHeadgearLocked(excludeCurrent: false); + RandomizeShirtLocked(); + RandomizeTrousersLocked(); + RandomizeFootwearLocked(); + RandomizeTemplateLocked(); + if (_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? finalHeritage)) + RandomizeStartAreaLocked(finalHeritage); + } + + /// Public command surface for — + /// consumed by the App layer's screen-open edge (mirrors + /// gmCharGenMainUI's ctor-time roll, retiring AP-214's + /// honest-blank deviation) and the Summary page's Random button + /// (gmCharGenMainUI::DoRandom case 5, behind the + /// ID_CharGen_RandomizeWarning confirmation the App layer + /// owns). + internal bool TryRandomizeCharacter() + { + lock (_gate) + { + if (_disposed || !_active) + return false; + RandomizeCharacterLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Public command surface for — + /// the Appearance page's Random button when its Face sub-tab is showing + /// (gmCharGenMainUI::DoRandom case 3's else arm). + internal bool TryRandomizeAppearance() + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0 || _genderKey == 0) + return false; + RandomizeAppearanceLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Public command surface for + /// with excludeCurrent: true — the Appearance page's Random + /// button when its Clothes sub-tab is showing + /// (gmCharGenMainUI::DoRandom case 3's + /// RandomizeClothing(state, 1) arm). + internal bool TryRandomizeClothing() + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0 || _genderKey == 0) + return false; + RandomizeClothingLocked(excludeCurrent: true); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + // ── Town / name / slot ───────────────────────────────────────────── /// Ports CharGenState::SetStartArea @ 0x005C4000 — bounds @@ -1290,16 +1682,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable refusal = trimmed.Length == 0 ? new RuntimeCharacterCreationLocalRefusal( NoName: true, false, false, false) - : !confirmedUnspentCredits && _remainingAttributeCredits > 0 + : _heritageId == 0 || _genderKey == 0 ? new RuntimeCharacterCreationLocalRefusal( - false, AttributeCreditsUnspent: true, false, false) - : _verificationPending + false, false, false, false, HeritageOrGenderUnset: true) + : !confirmedUnspentCredits && _remainingAttributeCredits > 0 ? new RuntimeCharacterCreationLocalRefusal( - false, false, AlreadyPending: true, false) - : slotCount > 0 && rosterCount >= slotCount + false, AttributeCreditsUnspent: true, false, false) + : _verificationPending ? new RuntimeCharacterCreationLocalRefusal( - false, false, false, RosterFull: true) - : RuntimeCharacterCreationLocalRefusal.None; + false, false, AlreadyPending: true, false) + : slotCount > 0 && rosterCount >= slotCount + ? new RuntimeCharacterCreationLocalRefusal( + false, false, false, RosterFull: true) + : RuntimeCharacterCreationLocalRefusal.None; _lastLocalRefusal = refusal; accepted = !refusal.Any; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 347b092f..d159e27d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -122,7 +122,8 @@ public sealed class CharacterCreationLiveDatTests CharacterCreationUiController? controller = CharacterCreationUiController.CreateDetached( host, screen, ResolveTemplate, dialogs, bindings, - new CharacterCreationUiController.DialogStrings("Are you sure?")); + new CharacterCreationUiController.DialogStrings( + "Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long")); Assert.NotNull(controller); controller!.AttachAndTick(); controller.Dispose(); @@ -515,6 +516,62 @@ public sealed class CharacterCreationLiveDatTests return new RetailDialogFactory(host, CreateLayout); } + /// + /// Campaign CC slice CC5 — the Summary page's full authored widget + /// catalog: the name field (with NameInputFilter), the how-to + /// text, the viewport (Summary's OWN gmCG3DView), and the + /// listbox's THREE row templates (single-line, category-header, + /// key/value pair) confirmed against the installed EoR dat — see + /// 's own class doc for the + /// decomp citation (SetSummaryText @ 0x0047b1d0) each template + /// maps to. + /// + [InstalledDatFact] + public void SummaryPage_HasNameFieldListboxTemplatesAndViewport() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement summaryRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.SummaryPageElementId)); + + Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ScrollId)); + Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.NameTextId)); + Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.HowToTextId)); + Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ViewportId)); + + UiTemplateListBox list = Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId)); + Assert.Equal(3, list.Templates.Count); + + UiElement? ResolveRow(int index) => + LayoutImporter.Import( + dats, + list.Templates[index].TemplateLayoutId, + list.Templates[index].TemplateElementId, + _ => (0u, 0, 0), + null)?.Root; + + UiElement lineRow = Assert.IsAssignableFrom(ResolveRow(0)); + Assert.IsType(UiElement.FindDescendant(lineRow, 0x100002F9u)); + + UiElement headerRow = Assert.IsAssignableFrom(ResolveRow(1)); + Assert.IsType(UiElement.FindDescendant(headerRow, 0x100000FEu)); + + UiElement pairRow = Assert.IsAssignableFrom(ResolveRow(2)); + Assert.IsType(UiElement.FindDescendant(pairRow, 0x100002FCu)); + Assert.IsType(UiElement.FindDescendant(pairRow, 0x100002FDu)); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 567391bb..a12b8397 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -93,18 +94,32 @@ public sealed class CharacterCreationUiControllerTests CharacterCreationUiController.SummaryPageElementId).Visible); } + /// Campaign CC slice CC5: Finish (0x100003c8) is + /// ghosted everywhere EXCEPT Summary — retail's own + /// ListenToElementMessage case only sends when + /// m_eProgressState == ECG_SUMMARY; off Summary the button now + /// has a real OnClick handler (OnFinish, which itself + /// re-checks the current page) but stays disabled. [Fact] - public void Finish_StaysGhosted_NoOnClickHandler() + public void Finish_GhostedExceptOnSummary() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId); - Assert.Null(finish.OnClick); + Assert.NotNull(finish.OnClick); Assert.False(finish.Enabled); + + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + Assert.True(finish.Enabled); } + /// Campaign CC slice CC5: Random (0x100003cb) is now + /// enabled on Appearance and Summary too — CC5 ports + /// RandomizeAppearance/RandomizeClothing/RandomizeCharacter, retiring + /// both gaps AP-212 used to track. Only Skills' unported + /// RandomizeSkills keeps Random disabled. [Fact] - public void Random_IsDisabledOnSkillsAppearanceAndSummaryPages() + public void Random_IsDisabledOnSkillsPageOnly() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); @@ -115,10 +130,10 @@ public sealed class CharacterCreationUiControllerTests Assert.False(random.Enabled); environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); - Assert.False(random.Enabled); + Assert.True(random.Enabled); environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); - Assert.False(random.Enabled); + Assert.True(random.Enabled); environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!(); Assert.True(random.Enabled); @@ -791,6 +806,304 @@ public sealed class CharacterCreationUiControllerTests environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!(); } + // ── CC5: RandomizeCharacter open-roll + gender flip ───────────────── + + /// Ports gmCharGenMainUI's ctor-time roll + + /// gmCGAppearancePage::InitializePage's gender-flip + /// (~0x004802da-0x00480303) — retiring AP-214's honest-blank + /// deviation. lands + /// deterministically on / + /// so the flip assertion + /// isn't flaky. + [Fact] + public void Open_RollsACharacterThenFlipsTheGenderToTheOpposite() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.RandomizedHeritageId = AluvianId; + environment.Runtime.RandomizedGenderKey = GenderKey; // Male = 1 + + environment.Controller.Open(); + + Assert.Equal(1, environment.Runtime.RandomizeCharacterCalls); + Assert.Equal(AluvianId, environment.Runtime.View.Snapshot.HeritageId); + // RandomizeCharacter rolled Male (1); InitializePage's own flip + // immediately inverts it to Female (2). + Assert.Equal(2u, environment.Runtime.LastSelectedGender); + Assert.Equal(2u, environment.Runtime.View.Snapshot.GenderKey); + } + + [Fact] + public void Open_RandomizeCharacterRejected_DoesNotAttemptTheGenderFlip() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.RandomizeCharacterAccepts = false; + + environment.Controller.Open(); + + Assert.Equal(0u, environment.Runtime.LastSelectedGender); + } + + // ── CC5: Finish (DoFinish @ 0x004E9170) ────────────────────────────── + + private static void GoToSummary(EnvironmentHarness environment) => + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + + [Fact] + public void Finish_EmptyName_ShowsNoNameWarningDialog_AndDoesNotSend() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("No name entered.", environment.LastDialogMessage()); + } + + [Fact] + public void Finish_UnspentCredits_ShowsCreditWarning_ConfirmResendsWithConfirmedFlag() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + Name = "Adventurer", + RemainingAttributeCredits = 6, + }; + GoToSummary(environment); + + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + Assert.False(environment.Runtime.LastConfirmedUnspentCredits); + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("You have unspent attribute credits.", environment.LastDialogMessage()); + + environment.ConfirmActiveDialog(confirmed: true); + + Assert.Equal(2, environment.Runtime.FinishCallCount); + Assert.True(environment.Runtime.LastConfirmedUnspentCredits); + } + + [Fact] + public void Finish_UnspentCredits_CancelDialog_DoesNotResend() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + Name = "Adventurer", + RemainingAttributeCredits = 6, + }; + GoToSummary(environment); + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + environment.ConfirmActiveDialog(confirmed: false); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + } + + [Fact] + public void Finish_Accepted_ShowsNoDialog() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + Name = "Adventurer", + RemainingAttributeCredits = 0, + }; + GoToSummary(environment); + + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + Assert.False(environment.Dialogs.IsOpen); + } + + [Fact] + public void Finish_OffSummaryPage_IsANoOp() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); // defaults to Heritage + + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + Assert.Equal(0, environment.Runtime.FinishCallCount); + } + + // ── CC5: Random on Summary (MakeRandomizeWarningDialog @ 0x004e8a90) ─ + + [Fact] + public void RandomOnSummary_ShowsWarningDialog_ConfirmCallsRandomizeCharacter() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + + environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); + + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("This will randomize your character.", environment.LastDialogMessage()); + int callsBeforeConfirm = environment.Runtime.RandomizeCharacterCalls; + + environment.ConfirmActiveDialog(confirmed: true); + + Assert.Equal(callsBeforeConfirm + 1, environment.Runtime.RandomizeCharacterCalls); + } + + [Fact] + public void RandomOnSummary_CancelDialog_DoesNotRandomize() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + int callsBeforeClick = environment.Runtime.RandomizeCharacterCalls; + + environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); + environment.ConfirmActiveDialog(confirmed: false); + + Assert.Equal(callsBeforeClick, environment.Runtime.RandomizeCharacterCalls); + } + + // ── CC5: Random on Appearance (DoRandom case 3) ────────────────────── + + [Fact] + public void RandomOnAppearance_FaceSubTab_CallsRandomizeAppearance() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + + environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.RandomizeAppearanceCalls); + Assert.Equal(0, environment.Runtime.RandomizeClothingCalls); + } + + [Fact] + public void RandomOnAppearance_ClothesSubTab_CallsRandomizeClothing() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!(); + + environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.RandomizeClothingCalls); + Assert.Equal(0, environment.Runtime.RandomizeAppearanceCalls); + } + + // ── CC5: Summary name field (ListenToElementMessage @ 0x0047bf40) ──── + + [Fact] + public void SummaryNameField_Submit_CommitsTheName() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + UiField field = environment.SummaryNameField(); + + field.SetText("Adventurer"); + field.Submit(); + + Assert.Equal("Adventurer", environment.Runtime.LastSetName); + } + + [Fact] + public void SummaryNameField_TooLong_ShowsDialogAndRevertsToLastCommitted() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + UiField field = environment.SummaryNameField(); + field.SetText("Adventurer"); + field.Submit(); + Assert.Equal("Adventurer", environment.Runtime.LastSetName); + + field.SetText(new string('a', 40)); + field.Submit(); + + // The over-limit text never reached SetName, and the too-long + // dialog fired with the field reverted to the last commit. + Assert.Equal("Adventurer", environment.Runtime.LastSetName); + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("That name is too long.", environment.LastDialogMessage()); + Assert.Equal("Adventurer", field.Text); + } + + [Fact] + public void SummaryNameField_NameInputFilter_RejectsDigitsAndSymbols() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + UiField field = environment.SummaryNameField(); + + Assert.NotNull(field.CharacterFilter); + Assert.True(field.CharacterFilter!('A')); + Assert.True(field.CharacterFilter!(' ')); + Assert.True(field.CharacterFilter!('\'')); + Assert.True(field.CharacterFilter!('-')); + Assert.False(field.CharacterFilter!('7')); + Assert.False(field.CharacterFilter!('$')); + } + + // ── CC5: 0xF643 rejection dialogs ───────────────────────────────────── + + [Fact] + public void CreationFailed_NameInUse_ShowsTheRetailErrorDialog_AndAcknowledgesOnClose() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use."; + environment.Controller.Open(); + + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + LastRejection = new RuntimeCharacterCreationRejection( + 3u, + CharGenVerificationResponse.Code.NameInUse, + "NameInUse", + "Adventurer"), + }; + BumpRevisionAndTick(environment); + + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("That name is in use.", environment.LastDialogMessage()); + + environment.DismissActiveMessageDialog(); + + Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls); + } + + [Fact] + public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_Character_Err_NameBanned"] = "That name is banned."; + environment.Controller.Open(); + + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + LastRejection = new RuntimeCharacterCreationRejection( + 4u, CharGenVerificationResponse.Code.NameBanned, "NameBanned", "Adventurer"), + }; + BumpRevisionAndTick(environment); + Assert.Equal(1, environment.Dialogs.ActiveCount); + + // A second Tick with the SAME rejection instance (no revision bump, + // no new value) must not reopen the dialog — ReconcileDialogs runs + // every Tick, not just on revision change. + environment.Controller.Tick(); + Assert.Equal(1, environment.Dialogs.ActiveCount); + } + private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl { public int ZoomInCalls { get; private set; } @@ -850,7 +1163,11 @@ public sealed class CharacterCreationUiControllerTests Dialogs, Runtime.Bindings, new CharacterCreationUiController.DialogStrings( - "Are you sure you want to leave?"))); + "Are you sure you want to leave?", + "No name entered.", + "You have unspent attribute credits.", + "This will randomize your character.", + "That name is too long."))); Controller.AttachAndTick(); } @@ -874,6 +1191,9 @@ public sealed class CharacterCreationUiControllerTests public UiScrollbar ShadeScroll() => Assert.IsType(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId)); + public UiField SummaryNameField() => + Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.NameTextId)); + /// Confirms or cancels the MOST RECENTLY opened confirmation /// dialog, using 's real /// button ids off the layout the factory's createLayout @@ -889,10 +1209,39 @@ public sealed class CharacterCreationUiControllerTests button.OnClick!(); } + /// Same shape as , for a + /// plain informational () OK + /// dialog — /the 0xF643 + /// rejection dialogs use this shape, not confirm/cancel. + public void DismissActiveMessageDialog() + { + ImportedLayout dialog = _dialogLayouts[^1]; + UiButton button = Assert.IsType( + dialog.FindElement(RetailMessageDialogView.OkButtonId)); + button.OnClick!(); + } + + /// The MOST RECENTLY opened dialog's message text — + /// mirrors CharacterManagementUiControllerTests.Message's + /// own lookup shape (element id 0x3E, every + /// popup's + /// text child). + public string LastDialogMessage() => string.Join( + " ", + Assert.IsType(_dialogLayouts[^1].FindElement(0x3Eu)) + .LinesProvider() + .Select(static line => line.Text)); + private static UiElement? ResolveSkillRowTemplate( uint templateLayoutId, uint templateElementId) => - BuildSkillRowTemplate(templateElementId); + templateElementId switch + { + SummaryLineTemplateId => BuildSummaryLineTemplate(), + SummaryHeaderTemplateId => BuildSummaryHeaderTemplate(), + SummaryPairTemplateId => BuildSummaryPairTemplate(), + _ => BuildSkillRowTemplate(templateElementId), + }; public void Dispose() { @@ -919,11 +1268,16 @@ public sealed class CharacterCreationUiControllerTests skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized), skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained), SelectStartArea, - _ => Result(RuntimeCommandStatus.Accepted), + Finish, () => RequestExitCalls++, SetAppearanceIndex: SetAppearanceIndex, SetShade: SetShade, - ResolveText: _ => null, + ResolveText: key => ResolvedStrings.TryGetValue(key, out string? value) ? value : null, + SetName: SetName, + AcknowledgeRejection: AcknowledgeRejection, + RandomizeCharacter: RandomizeCharacter, + RandomizeAppearance: () => { RandomizeAppearanceCalls++; return Result(RuntimeCommandStatus.Accepted); }, + RandomizeClothing: () => { RandomizeClothingCalls++; return Result(RuntimeCommandStatus.Accepted); }, OpenOnStart: false); } @@ -942,6 +1296,36 @@ public sealed class CharacterCreationUiControllerTests public int AppearanceIndexCallCount { get; private set; } public ChargenShadeSlot? LastShadeSlot { get; private set; } public double LastShadeValue { get; private set; } + public string? LastSetName { get; private set; } + public int FinishCallCount { get; private set; } + public bool LastConfirmedUnspentCredits { get; private set; } + public int AcknowledgeRejectionCalls { get; private set; } + public int RandomizeCharacterCalls { get; private set; } + public int RandomizeAppearanceCalls { get; private set; } + public int RandomizeClothingCalls { get; private set; } + + /// + /// The exact HeritageId/GenderKey a fake RandomizeCharacter roll + /// lands on — deterministic (not random) so tests can assert the + /// flip-to-opposite-gender behavior precisely. Defaults to 0/0 (a + /// no-op "roll") so the 37 PRE-EXISTING Open() call sites in + /// this file — written against the honest-blank-open contract + /// AP-214 tracked before this slice — keep observing a blank + /// heritage/gender after Open() without every one of them + /// having to opt out individually; only the tests THIS slice adds + /// that specifically exercise the roll set these explicitly. + /// + public uint RandomizedHeritageId { get; set; } + public uint RandomizedGenderKey { get; set; } + + /// Lets a test simulate the Runtime-inactive/rejected case + /// (e.g. a session already gone) without needing a real + /// RuntimeCharacterCreationState. + public bool RandomizeCharacterAccepts { get; set; } = true; + + /// Populated by tests exercising the ID_Character_Err_* + /// rejection-dialog path — ResolveText above reads from it. + public Dictionary ResolvedStrings { get; } = []; public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId); @@ -1007,6 +1391,65 @@ public sealed class CharacterCreationUiControllerTests return Result(RuntimeCommandStatus.Accepted); } + private RuntimeCommandResult SetName(string name) + { + LastSetName = name; + View.Snapshot = View.Snapshot with { Name = name }; + return Result(RuntimeCommandStatus.Accepted); + } + + /// Mirrors RuntimeCharacterCreationState.TryBeginFinish's + /// refusal-priority chain closely enough to drive + /// CharacterCreationUiController.TryFinish's own dialog + /// dispatch under test — NoName, then HeritageOrGenderUnset, then + /// (unless confirmed) AttributeCreditsUnspent, else Accepted. + private RuntimeCommandResult Finish(bool confirmUnspentCredits) + { + FinishCallCount++; + LastConfirmedUnspentCredits = confirmUnspentCredits; + RuntimeCharacterCreationSnapshot snapshot = View.Snapshot; + string trimmed = snapshot.Name.Trim(); + + RuntimeCharacterCreationLocalRefusal refusal = trimmed.Length == 0 + ? new RuntimeCharacterCreationLocalRefusal(NoName: true, false, false, false) + : snapshot.HeritageId == 0u || snapshot.GenderKey == 0u + ? new RuntimeCharacterCreationLocalRefusal( + false, false, false, false, HeritageOrGenderUnset: true) + : !confirmUnspentCredits && snapshot.RemainingAttributeCredits > 0 + ? new RuntimeCharacterCreationLocalRefusal( + false, AttributeCreditsUnspent: true, false, false) + : RuntimeCharacterCreationLocalRefusal.None; + + View.Snapshot = snapshot with { Name = trimmed, LastLocalRefusal = refusal }; + return Result(refusal.Any ? RuntimeCommandStatus.Rejected : RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult AcknowledgeRejection() + { + AcknowledgeRejectionCalls++; + View.Snapshot = View.Snapshot with { LastRejection = null }; + return Result(RuntimeCommandStatus.Accepted); + } + + /// Deterministic fake for + /// RuntimeCharacterCreationState.TryRandomizeCharacter — real + /// randomness would make the gender-flip assertion in + /// Open_RollsARandomCharacterThenFlipsAppearancePageGender + /// flaky, so this always lands on / + /// instead. + private RuntimeCommandResult RandomizeCharacter() + { + RandomizeCharacterCalls++; + if (!RandomizeCharacterAccepts) + return Result(RuntimeCommandStatus.Rejected); + View.Snapshot = View.Snapshot with + { + HeritageId = RandomizedHeritageId, + GenderKey = RandomizedGenderKey, + }; + return Result(RuntimeCommandStatus.Accepted); + } + private static RuntimeCharacterCreationAppearance WithAppearanceIndex( RuntimeCharacterCreationAppearance a, ChargenAppearanceSlot slot, @@ -1227,7 +1670,7 @@ public sealed class CharacterCreationUiControllerTests root.Children.Add(BuildSkillsPage()); root.Children.Add(BuildAppearancePage()); root.Children.Add(BuildTownPage()); - root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId)); + root.Children.Add(BuildSummaryPage()); root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId)); @@ -1419,6 +1862,77 @@ public sealed class CharacterCreationUiControllerTests _ => (0u, 0, 0), null).Root; + // ── Summary page fixture (CC5) ─────────────────────────────────────── + // Template element ids match the LIVE-DAT-probe-confirmed retail ones + // (CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport) + // for readability, though this hand-built fixture doesn't require it. + + private const uint SummaryLineTemplateId = 0x100002F8u; + private const uint SummaryHeaderTemplateId = 0x100002FAu; + private const uint SummaryPairTemplateId = 0x100002FBu; + + private static ElementInfo BuildSummaryPage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.SummaryPageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + + var list = new ElementInfo + { + Id = CharacterCreationSummaryPage.ListBoxId, + Type = 5u, + X = 20f, + Y = 40f, + Width = 400f, + Height = 300f, + }; + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryLineTemplateId)); + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryHeaderTemplateId)); + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryPairTemplateId)); + page.Children.Add(list); + + page.Children.Add(ScrollbarInfo(CharacterCreationSummaryPage.ScrollId)); + page.Children.Add(EditableFieldInfo(CharacterCreationSummaryPage.NameTextId)); + page.Children.Add(TextInfo(CharacterCreationSummaryPage.HowToTextId)); + + var viewport = new ElementInfo + { + Id = CharacterCreationSummaryPage.ViewportId, + Type = 0xDu, + Width = 300f, + Height = 300f, + }; + page.Children.Add(viewport); + + return page; + } + + private static UiElement BuildSummaryLineTemplate() + { + var root = new ElementInfo { Id = 0x90001u, Type = 3u, Width = 380f, Height = 16f }; + root.Children.Add(TextInfo(0x100002F9u)); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root; + } + + private static UiElement BuildSummaryHeaderTemplate() + { + var root = new ElementInfo { Id = 0x90002u, Type = 3u, Width = 380f, Height = 18f }; + root.Children.Add(TextInfo(0x100000FEu)); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root; + } + + private static UiElement BuildSummaryPairTemplate() + { + var root = new ElementInfo { Id = 0x90003u, Type = 3u, Width = 380f, Height = 16f }; + root.Children.Add(TextInfo(0x100002FCu)); + root.Children.Add(TextInfo(0x100002FDu)); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root; + } + private static ElementInfo ContainerInfo(uint id) => new() { Id = id, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs index 4e1480fc..452d5ff0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs @@ -290,7 +290,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests _dialogs, Runtime.Bindings, new CharacterCreationUiController.DialogStrings( - "Are you sure you want to leave?"))); + "Are you sure you want to leave?", + "No name", "Unspent credits", "Randomize?", "Name too long"))); Controller.AttachAndTick(); } diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs index cb06bb7a..2103550c 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs @@ -21,6 +21,15 @@ internal static class RuntimeCharacterCreationStateFixture public const uint ImpoverishedId = 90u; public const uint MaleGenderKey = 1u; + /// Campaign CC slice CC5: RandomizeCharacter's gender + /// roll (RollDice(1,2)) needs BOTH gender keys resolvable on the + /// four human heritages, or half of all random seeds would land on a + /// gender can't + /// resolve and silently leave appearance untouched (a real, harmless + /// ConstrainAllByGender-style fallback — but not what these tests + /// are pinning). + public const uint FemaleGenderKey = 2u; + /// str=10 end=10 coord=10 quick=10 focus=10 self=10 — the /// budget-66 heritage leaves 6 credits unspent after this template, /// matching retail's "Custom sits at the floor" finding. @@ -99,6 +108,16 @@ internal static class RuntimeCharacterCreationStateFixture Footwear: [new ChargenGearOption("Boots", 4u, 303u)], ClothingColors: [400u, 401u, 402u]); + // Same shape as `gender`, just the other GenderKey — every list is + // deliberately populated so a full RandomizeCharacter roll never + // finds an empty option list to skip. + var femaleGender = gender with { GenderKey = (int)FemaleGenderKey, Name = "Female" }; + var bothGenders = new Dictionary + { + [(int)MaleGenderKey] = gender, + [(int)FemaleGenderKey] = femaleGender, + }; + var aluvianTemplates = new List { new( @@ -129,7 +148,7 @@ internal static class RuntimeCharacterCreationStateFixture SecondaryStartAreaIndices: [], SkillCostsBySkillId: skillCosts, Templates: aluvianTemplates, - GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + GendersByKey: bothGenders); var olthoiTemplates = new List { @@ -163,6 +182,28 @@ internal static class RuntimeCharacterCreationStateFixture Templates: olthoiTemplates, GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + // Campaign CC slice CC5: CharGenState::RandomizeCharacter @ + // 0x005c6d80 rolls a heritage id uniformly in [1, hasToD?4:3] — the + // four HUMAN heritage groups (ChargenHeritageGroup.Aluvian.. + // Viamontian). RandomizeCharacterLocked's tests need every one of + // those four ids resolvable, not just Aluvian, so the roll can never + // silently land on a missing heritage. Ids 2-4 mirror Aluvian's own + // shape (same gender/template data) — the roll target, not the + // template/skill-budget math, is what those tests exercise. + ChargenHeritageOptions MakeHumanHeritage(uint id, string name) => new( + id, + name, + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 66u, + SkillCredits: 50u, + PrimaryStartAreaIndices: [0, 1], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: skillCosts, + Templates: aluvianTemplates, + GendersByKey: bothGenders); + // A deliberately impoverished heritage — just enough skill credits // to train SkillTrainSpecialize but never specialize it — so a // TrySpecializeSkill affordability refusal is directly testable @@ -198,6 +239,12 @@ internal static class RuntimeCharacterCreationStateFixture new Dictionary { [AluvianId] = aluvian, + [(uint)ChargenHeritageGroup.Gharundim] = MakeHumanHeritage( + (uint)ChargenHeritageGroup.Gharundim, "Gharu'ndim"), + [(uint)ChargenHeritageGroup.Sho] = MakeHumanHeritage( + (uint)ChargenHeritageGroup.Sho, "Sho"), + [(uint)ChargenHeritageGroup.Viamontian] = MakeHumanHeritage( + (uint)ChargenHeritageGroup.Viamontian, "Viamontian"), [OlthoiId] = olthoi, [ImpoverishedId] = impoverished, }, diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs index 175364ea..d73d38b2 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -555,6 +555,42 @@ public sealed class RuntimeCharacterCreationStateTests Assert.True(refusal.AlreadyPending); } + /// + /// F12 amendment (CC6b-MOUNT review fix round, filed as register + /// AP-223): with AD-101 retired, a caller could otherwise reach Finish + /// with heritage/gender still unset. Retail's own DoFinish never + /// checks this because RandomizeCharacter guarantees it can't + /// happen — this is acdream's own defensive backstop for any caller that + /// bypasses the App layer's screen-open roll. + /// + [Fact] + public void TryBeginFinish_HeritageUnset_IsRefused() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySetName("Adventurer"); + + bool accepted = state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.HeritageOrGenderUnset); + Assert.False(refusal.NoName); + } + + [Fact] + public void TryBeginFinish_GenderUnset_IsRefused() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySetName("Adventurer"); + + bool accepted = state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.HeritageOrGenderUnset); + } + [Fact] public void TryBeginFinish_RosterAtSlotCap_IsRefused() { @@ -700,4 +736,162 @@ public sealed class RuntimeCharacterCreationStateTests Assert.Null(state.Snapshot.LastRejection); } + + // ── Randomize (Campaign CC slice CC5, RandomizeCharacter port) ─────── + + /// + /// Ports CharGenState::RandomizeCharacter @ 0x005c6d80: rolls a + /// heritage id in [1,4] (the four HUMAN groups, never the other nine), + /// a gender in [1,2], and freezes both non-Unset. A 200-iteration sweep + /// with a fresh seeded RNG per iteration proves the heritage roll never + /// escapes the 1-4 human-only range even though the fixture ALSO + /// carries a non-human Olthoi heritage (id 12) and an out-of-range + /// "Impoverished" heritage (id 90) that a broken roll could otherwise + /// land on. + /// + [Fact] + public void TryRandomizeCharacter_RollsOnlyTheFourHumanHeritagesAndAGender() + { + for (int seed = 0; seed < 200; seed++) + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(seed)); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TryRandomizeCharacter()); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.InRange(snapshot.HeritageId, 1u, 4u); + Assert.True(snapshot.GenderKey is 1u or 2u); + } + } + + [Fact] + public void TryRandomizeCharacter_RollsAppearanceClothingTemplateAndStartArea() + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(7)); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TryRandomizeCharacter()); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + // Every list in the fixture's shared gender record is non-empty, so + // a full randomize must leave nothing Unset. + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.EyesStrip); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairColor); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.ShirtStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.TrousersStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.FootwearStyle); + Assert.NotEqual(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); + // Template is one of the PRESET rows (never index 0/Custom) — + // RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape. + Assert.NotEqual(0u, snapshot.Template); + Assert.True(snapshot.StartArea is 0 or 1); + } + + /// + /// RandomizeTemplateLocked's own Olthoi/OlthoiAcid branch + /// (mirroring CharGenState::RandomizeTemplate's force-to-template-0 + /// arm) is UNREACHABLE through + /// specifically — that caller's own heritage roll is always one of the + /// four HUMAN ids (never Olthoi), matching retail's identical + /// architecture (RandomizeCharacter's heritage roll and + /// RandomizeTemplate's Olthoi branch are independent call paths; + /// retail never composes them either, since a random CHARACTER is never + /// Olthoi). The branch is not otherwise exposed as a standalone command + /// this slice (out of CC5's named scope), so its OBSERVABLE behavior — + /// selecting Olthoi always forces template 0 — is already covered by + /// TrySelectHeritage_Olthoi.../ApplyTemplate coverage + /// elsewhere in this file; this test only pins that a full + /// TryRandomizeCharacter roll never lands on Olthoi in the first + /// place, over enough iterations to catch a boundary-off-by-one. + /// + [Fact] + public void TryRandomizeCharacter_NeverRollsANonHumanHeritage() + { + for (int seed = 0; seed < 200; seed++) + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(seed)); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TryRandomizeCharacter()); + + Assert.NotEqual(RuntimeCharacterCreationStateFixture.OlthoiId, state.Snapshot.HeritageId); + Assert.NotEqual(RuntimeCharacterCreationStateFixture.ImpoverishedId, state.Snapshot.HeritageId); + } + } + + [Fact] + public void TryRandomizeCharacter_Inactive_IsRejected() + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build()); + Assert.False(state.TryRandomizeCharacter()); + } + + [Fact] + public void TryRandomizeAppearance_RequiresHeritageAndGender() + { + RuntimeCharacterCreationState state = CreateActive(); + Assert.False(state.TryRandomizeAppearance()); + + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + Assert.False(state.TryRandomizeAppearance()); + + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + Assert.True(state.TryRandomizeAppearance()); + Assert.NotEqual( + RuntimeCharacterCreationAppearance.Unset, + state.Snapshot.Appearance.HairStyle); + } + + [Fact] + public void TryRandomizeClothing_RollsAllFourGearSlots() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + Assert.True(state.TryRandomizeClothing()); + + RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance; + // Headgear excludes-current with the +1 Unset-ring reindex — the + // fixture's single headgear style means the roll can only land on + // style 0 or Unset; either is a valid outcome of the ring, so this + // just confirms the call actually touched the field (shirt/trousers/ + // footwear below have no Unset ring and must land on their one + // style). + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.ShirtStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.TrousersStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.FootwearStyle); + } + + /// + /// RandInt(int,int) @ 0x00684420's own decompiled shape: re-roll + /// until the result differs from the excluded value, UNLESS there is + /// only one possible outcome ( <= 1), which + /// returns 0 immediately without ever comparing against exclude (the + /// guard that keeps the loop from spinning forever). This drives + /// + /// enough times to statistically prove the shirt slot (the fixture's + /// single-style list) never gets stuck — count<=1 must short-circuit. + /// + [Fact] + public void TryRandomizeClothing_SingleOptionList_NeverHangs() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + for (int i = 0; i < 50; i++) + Assert.True(state.TryRandomizeClothing()); + + Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle); + } }