From ec854db045da39b9dccf103fb3a1c2e2110ce572 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 18:29:49 +0200 Subject: [PATCH] =?UTF-8?q?fix(app,runtime,headless):=20Campaign=20CC=20sl?= =?UTF-8?q?ice=20CC4=20review-fix=20round=20=E2=80=94=20F1-F12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dual-lens review of CC4 (0e71d3b8) returned architectural FAIL (F1, F6) and retail-fidelity PASS-with-reservations (F2, F3, F4), plus LOW findings F5, F7-F12. F13 (TS-82's merge collision with campaign-cc6a) is merge mechanics for the orchestrator, not addressed here. F1 (HIGH, blocking): CharacterCreationUiController never released UiRoot.FixedCanvasSize, on a FALSE premise that CharacterManagementUi- Controller does a per-tick set (it does not — it sets once on activation and nulls on Deactivate/Dispose). Root cause: RuntimeCharacterCreation- State had no CompleteEnter() analogue to RuntimeCharacterSelectionState's, so the creation view reported IsActive=true for an entire in-world session. Added CompleteEnter(), wired at both LiveSessionController in-world edges (StartCore, EnterHighlightedCore); made Open/Close/ Deactivate/Dispose set/null the canvas symmetrically; corrected the false comment and ledger claim; added FixedCanvasSize test coverage. F2 (MEDIUM-HIGH, blocking): the attribute-slider scalar mapping was not retail's. Fixed display to value/100f (UpdateAttributeValues @ 0x0048251d) and the drag inverse to truncate+clamp-low-only, no rescale (ListenToElementMessage @ 0x004829c0, independently re-verified against the decomp). Added tests at scalar 0.5/0.0 plus a display-direction test. F3 (MEDIUM, blocking): ported the unported heritage-button tab-restore arm (ListenToElementMessage @ 0x004e9450) — SHOW/HIDE id sets independently re-derived from the decomp, including the genuine Lugian (0x100005f1) no-restore quirk, reproduced faithfully. Wired via a new HeritagePage click callback; added restore + quirk tests. F4 (MEDIUM): ported SetTown's (@ 0x0047c360) separate per-town page-root state literal (Holtburg->0x10000034 etc.), independently re-derived from the decomp's tail-merged branches; wired via the existing IUiDatStateful.TrySetRetailState seam; added a test. F5 (MEDIUM): softened AD-103's unmeasured pixel-equivalence claim. F6 (MEDIUM, blocking): DECISION — install ChargenOptions in the headless content path (chosen over marking headless creation out-of-scope). HeadlessSessionHost now calls InstallOptions off the shared content lease's Dats, beside the existing InstallSpellMetadata call. F7: AP-213 already named the label format and click/double-click substitution explicitly on inspection — no edit needed. F8: AP-212 now names all six DoRandom primitives with a known landing site. F9: AD-101 retirement corrected to precede CC5's Finish un-ghosting. F10: merged ItemAppraisalTextFormatter's duplicate block. F11: fixed TS-82's wrong AP-211 cross-reference. F12: cached the chargen DatStringResolver once per composition instead of per ResolveText call. Runtime 1713/0, App 5125/13 skips (+8 new tests), Headless 165/0, full solution Release build green. Live-DAT probes 7/7 under ACDREAM_PROBE_LIVE_MOUNT=1. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 8 +- .../2026-08-15-character-creation-campaign.md | 2 +- .../InteractionRetainedUiComposition.cs | 15 +- .../Layout/CharacterCreationHeritagePage.cs | 18 +- .../Layout/CharacterCreationProfessionPage.cs | 23 ++- .../UI/Layout/CharacterCreationTownPage.cs | 32 +++ .../Layout/CharacterCreationUiController.cs | 91 ++++++-- .../UI/Layout/ItemAppraisalTextFormatter.cs | 8 +- .../Hosting/HeadlessSessionHost.cs | 17 ++ .../Session/LiveSessionController.cs | 7 + .../Session/RuntimeCharacterCreationState.cs | 33 +++ .../CharacterCreationUiControllerTests.cs | 195 ++++++++++++++++++ 12 files changed, 419 insertions(+), 30 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index df7934b4..137b8230 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -193,9 +193,9 @@ readiness/requeue adaptation. See | AD-97 | **Filed 2026-08-14 at Campaign LA slice LA7a (character-restore request tail).** Retail's `CharacterRestore` request (`0xF7D9`) is ≥16 bytes: `CPlayerSystem::RestoreCharacter @0x0055d760` is, in the PDB-paired binary, `push 0x008173B4; push 0x008173B4; push guid; call Proto_UI::SendAdminRestoreCharacter @0x00546cf0`, and the callee packs BOTH constant `PStringBase*` arguments (`PStringBase::Pack @0x004fc6f0` emits ≥4 bytes even empty). Binary Ninja renders the two pushes as an uninitialized `edx` local plus `this` — a rendering artifact around constant `0x008173B4` (all 3 of its other pseudo-C appearances sit in provably-broken decompiles), but the arguments are real. acdream sends the 8-byte guid-only form. What the two constant strings contain is unresolved (a live cdb `db poi(0x008173b4)` would settle it). | `src/AcDream.Core.Net/Messages/CharacterRestore.cs` (`BuildRequestBody`) | ACE reads only `ReadUInt32()` and ignores any tail (`CharacterHandler.cs:331-385`), and holtburger ships guid-only from a real client command path against ACE successfully — the tail is unread by every server we can test against, and packing two strings whose CONTENT we cannot verify would be a guess. | A byte-capture comparison against a real retail client differs from offset 8; a future server that validates the full retail shape would reject our 8-byte request. | `CPlayerSystem::RestoreCharacter @0x0055d760` (binary bytes, not the BN rendering); `Proto_UI::SendAdminRestoreCharacter @0x00546cf0`; `PStringBase::Pack @0x004fc6f0`; ACE `CharacterHandler.cs:331-385`; holtburger `character_selection.rs:79-82`; LA7a Opus review F1 (2026-08-14) | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | | AD-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | -| AD-103 | **Filed 2026-08-15 at Campaign CC slice CC4 (chargen avail/health/stamina/mana displays and the Skills page credits meter).** Retail's `gmCGProfessionPage`/`gmCGSkillsPage` address these five values as independently-addressable `UIElement_Text` children (`DynamicCast(0xc)`) nested one level under a `UIElement_Button` container/badge (decomp ids `0x100002f1`/`0x100002f3` under `0x100003e2..e5` and `0x100003f9`). acdream's `UiButton.ConsumesDatChildren` swallows every dat child of a Type-1 element at import time (it treats them as label/face art, never as independently addressable overlay widgets — the same convention `UiMeter`'s explicit Type-12 carve-out exists to work around). Live-DAT probe evidence (`CharacterCreationLiveDatTests`) confirms this shape in the installed EoR build. acdream substitutes the CONTAINER button's own `.Label` for the swallowed child's text — same visible number, different addressable widget. | `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`_availableValue`/`_healthValue`/`_staminaValue`/`_manaValue`, `SetDisplay`); `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`_credits`) | `UiButton.ConsumesDatChildren` is a structural, campaign-wide convention (shared with every other retained-UI button in the client, not special-cased for chargen); reproducing retail's literal nested-overlay-widget tree here would require the SAME `UiMeter`-style carve-out for every button that happens to author a Type-12 child, a wider change than this slice's scope. The composited pixel result (a number inside a bordered badge) is unchanged. | If a future consumer needs to address the value text independently of the badge button (e.g. per-glyph styling different from the button's label font), this substitution has no seam for it without extending `DatWidgetFactory`. | `gmCGProfessionPage::InitializePage @ 0x00482d50`; `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450`; `gmCGSkillsPage::InitializePage @ 0x00481dd0`; `gmCGSkillsPage::UpdateCreditsMeter @ 0x004808f0`; `CharacterCreationLiveDatTests.ProfessionPage_HasTemplateButtonsSlidersAndDisplays`/`SkillsPage_HasListboxCreditsAndInfoPanes` | +| AD-103 | **Filed 2026-08-15 at Campaign CC slice CC4 (chargen avail/health/stamina/mana displays and the Skills page credits meter).** Retail's `gmCGProfessionPage`/`gmCGSkillsPage` address these five values as independently-addressable `UIElement_Text` children (`DynamicCast(0xc)`) nested one level under a `UIElement_Button` container/badge (decomp ids `0x100002f1`/`0x100002f3` under `0x100003e2..e5` and `0x100003f9`). acdream's `UiButton.ConsumesDatChildren` swallows every dat child of a Type-1 element at import time (it treats them as label/face art, never as independently addressable overlay widgets — the same convention `UiMeter`'s explicit Type-12 carve-out exists to work around). Live-DAT probe evidence (`CharacterCreationLiveDatTests`) confirms this shape in the installed EoR build. acdream substitutes the CONTAINER button's own `.Label` for the swallowed child's text — same visible number, different addressable widget. | `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`_availableValue`/`_healthValue`/`_staminaValue`/`_manaValue`, `SetDisplay`); `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`_credits`) | `UiButton.ConsumesDatChildren` is a structural, campaign-wide convention (shared with every other retained-UI button in the client, not special-cased for chargen); reproducing retail's literal nested-overlay-widget tree here would require the SAME `UiMeter`-style carve-out for every button that happens to author a Type-12 child, a wider change than this slice's scope. **Review fix round F5 (2026-08-15): the composited pixel result is EXPECTED unchanged (same number, same badge) but NOT measured** — `UiButton.ConsumesDatChildren` discards the child's authored rect/font/justify entirely rather than rebuilding at the child's dat-local coordinates the way `UiMeter`'s carve-out does, and `CharacterCreationLiveDatTests` asserts only widget TYPE (button vs. the swallowed Type-12), not the rendered rect/font/justify of the substituted `.Label` against what the discarded child would have drawn. Treat the equivalence claim as unverified until a probe compares them. | If a future consumer needs to address the value text independently of the badge button (e.g. per-glyph styling different from the button's label font), this substitution has no seam for it without extending `DatWidgetFactory`; separately, closing the pixel-equivalence gap above needs either a rect/justify comparison probe or a `UiMeter`-style carve-out. | `gmCGProfessionPage::InitializePage @ 0x00482d50`; `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450`; `gmCGSkillsPage::InitializePage @ 0x00481dd0`; `gmCGSkillsPage::UpdateCreditsMeter @ 0x004808f0`; `CharacterCreationLiveDatTests.ProfessionPage_HasTemplateButtonsSlidersAndDisplays`/`SkillsPage_HasListboxCreditsAndInfoPanes` | | AD-102 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Heritage page's Viamontian button and the Town page's Sanamar button).** Retail gates BOTH controls behind `CPlayerSystem::AccountHasThroneOfDestiny`: `gmCGHeritagePage::ListenToElementMessage @ 0x00483860` shows `MakeToDWarningDialog` instead of selecting Viamontian (element `0x100003c3`) for a non-ToD account, and `gmCGTownPage::ListenToElementMessage @ 0x0047c480` does the same for Sanamar (element `0x1000040b`, `startArea` index 3 — also the reason `CharGenState::RandomizeStartArea`'s ToD-aware `RandInt(3 or 4)` bound exists). acdream's `ChargenOptions` (CC1) carries no account/DLC-ownership signal anywhere in the model, so both controls ship WITHOUT the gate — every installed heritage/town in `Options.HeritagesById`/`Options.StarterAreas` is always selectable, matching what a ToD-owning account would see. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`HeritageByButtonId[0x100003C3u]`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`StartAreaByButtonId[0x1000040Bu]`, `Randomize`) | ACE's server-side `CharacterCreate` handler never checks ToD ownership either (the field is purely a retail-client UI gate), so accepting the selection unconditionally never produces a request the emulator would reject; adding an account-ownership model to CC1's DAT-only `ChargenOptions` is out of this slice's scope and would need its own design (where does the "ToD owned" bit come from — account service, launcher config, a new env flag?). | None observable against ACE. A future retail-parity gate that specifically checks "does a non-ToD account get warned off Viamontian/Sanamar" will fail until an account-ownership signal exists to gate on. | `gmCGHeritagePage::ListenToElementMessage @ 0x00483860`; `gmCGTownPage::ListenToElementMessage @ 0x0047c480`; `gmCGTownPage::SetTown @ 0x0047c360`; `CharGenState::RandomizeStartArea` (DoRandom case 4, `RandInt(hasToD ? 4 : 3)`) | -| AD-101 | **Filed 2026-08-15 at Campaign CC slice CC4 (Heritage-page auto-gender-select).** Retail's Profession-page template application (`CharGenState::ApplyTemplate @ 0x005C5080`, reached from `TrySelectTemplate`) requires both heritage AND gender to already be selected. Retail's OWN gender controls (`0x100003a7`/`0x100003a8`) live on the Appearance page (`gmCGAppearancePage @ 0x0047de70`), which this slice deliberately mounts as an empty, content-inert placeholder — CC6b's explicit scope per the campaign's parallelism contract. Without SOME gender selection, the Profession/Skills/Town pages CC4 builds would be permanently unusable (every `SelectTemplate`/skill/town command silently refused by `RuntimeCharacterCreationState`'s heritage+gender gate) until CC6b lands. `CharacterCreationHeritagePage.Select` therefore auto-selects the chosen heritage's numerically-lowest `GendersByKey` entry immediately after a successful `SelectHeritage`, with no player-visible gender-choice UI this round. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`) | CC6b's real gender buttons are a strict superset of this behavior (an explicit player choice instead of an implicit default) and will make this row's auto-select unreachable/moot once wired — retire this row then. Until then, every heritage's genders differ only in appearance-option lists (never in attribute/skill/template data — CC1's model), so which gender is implicitly selected has no effect on any value CC4's pages read or write. | A heritage with per-gender TEMPLATE or SKILL differences (none exist in the installed DAT per CC1's gates) would silently commit to the wrong gender's data; a player who would have picked the other gender gets no chance to before Profession/Skills/Town become interactive. | `CharGenState::ApplyTemplate @ 0x005C5080`; `gmCGAppearancePage @ 0x0047de70` (gender buttons `0x100003a7`/`0x100003a8`, unbuilt this round); `RuntimeCharacterCreationState.TrySelectTemplate`'s heritage/gender gate | +| AD-101 | **Filed 2026-08-15 at Campaign CC slice CC4 (Heritage-page auto-gender-select).** Retail's Profession-page template application (`CharGenState::ApplyTemplate @ 0x005C5080`, reached from `TrySelectTemplate`) requires both heritage AND gender to already be selected. Retail's OWN gender controls (`0x100003a7`/`0x100003a8`) live on the Appearance page (`gmCGAppearancePage @ 0x0047de70`), which this slice deliberately mounts as an empty, content-inert placeholder — CC6b's explicit scope per the campaign's parallelism contract. Without SOME gender selection, the Profession/Skills/Town pages CC4 builds would be permanently unusable (every `SelectTemplate`/skill/town command silently refused by `RuntimeCharacterCreationState`'s heritage+gender gate) until CC6b lands. `CharacterCreationHeritagePage.Select` therefore auto-selects the chosen heritage's numerically-lowest `GendersByKey` entry immediately after a successful `SelectHeritage`, with no player-visible gender-choice UI this round. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`) | CC6b's real gender buttons are a strict superset of this behavior (an explicit player choice instead of an implicit default) and will make this row's auto-select unreachable/moot once wired. **Review fix round F9 (2026-08-15) — retirement sequencing correction: this row MUST retire before CC5's Finish un-ghosts, not merely "at CC6b."** CC5 (Summary page + the real Finish gate) lands before CC6b in the campaign's own slice order; if Finish un-ghosts while this row is still live, a create can complete end-to-end on an IMPLICIT gender default the player never chose or saw — CC6b's explicit gender buttons must land no later than CC5's Finish wiring, or CC5 must itself surface the implicit choice, whichever the campaign plan schedules first. Until retired, every heritage's genders differ only in appearance-option lists (never in attribute/skill/template data — CC1's model), so which gender is implicitly selected has no effect on any value CC4's pages read or write. | A heritage with per-gender TEMPLATE or SKILL differences (none exist in the installed DAT per CC1's gates) would silently commit to the wrong gender's data; a player who would have picked the other gender gets no chance to before Profession/Skills/Town become interactive; worse, if CC5 ships Finish before this row retires, a real character can be CREATED with a gender the player never picked. | `CharGenState::ApplyTemplate @ 0x005C5080`; `gmCGAppearancePage @ 0x0047de70` (gender buttons `0x100003a7`/`0x100003a8`, unbuilt this round); `RuntimeCharacterCreationState.TrySelectTemplate`'s heritage/gender gate | | AD-99 | **Filed 2026-08-15 at Campaign LA gate round 2 finding 1 (character-select Exit button).** On a confirmed Exit, acdream closes the client through the existing graceful window-close path (`d.Window.Close`, the same seam `GameplayInputCommandController`'s in-world Escape fallback already uses) instead of retail's real post-confirm behavior: `RecvNotice_CloseDialog`'s case-1 arm queues UI mode `0x10000009`, which `gmEpilogueUI::Register` claims — a brief epilogue/farewell screen — before the process actually terminates. The confirmation dialog itself (`MakeConfirmExitDialog`, its exact `ID_CharacterManagement_ConfirmExit` text, and the `m_confirmExitDialogContext != 0` re-entry guard) IS ported faithfully; only the post-confirm destination differs, the same shape as AD-74's Options-panel exit. | `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (`RequestExit`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`CharacterSelectionRuntimeBindings.RequestExit`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`d.Window.Close` binding) | acdream has no `gmEpilogueUI` port (out of scope this round); reusing the ONE existing graceful-shutdown seam keeps `disconnected`/`exited` status events firing through `GameWindow.OnClosing` → `CompleteShutdown` rather than inventing a second shutdown path, per explicit direction for this finding. | A user confirming Exit sees the window close immediately instead of retail's brief epilogue screen; a future feature wanting to reproduce that screen (or an intermediate "logged off, returned to character select" state) has no seam yet — same gap class as AD-44. | `gmCharacterManagementUI::MakeConfirmExitDialog @0x004ed250`; `RecvNotice_CloseDialog @0x004ed760` case 1; `gmEpilogueUI::Register(0x10000009)` @0x0047a680; `gmCharacterManagementUI::OnAction @0x004ed410` (Escape key, unported — button-only this round) | --- @@ -391,7 +391,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | | AP-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`).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` dispatches per-page to `CharGenState::RandomizeHeritageGroup`/`RandomizeTemplate`/`RandomizeSkills`/`SetStartArea(RandInt(hasToD ? 4 : 3))` — none of which CC3's Runtime command surface exposes as a primitive. 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 (no `RandomizeSkills` equivalent exists at all), Appearance (this round's placeholder), and Summary (the randomize-WARNING dialog is CC5's). | `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 three more retail algorithms this slice did not scope. | 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 has no Random affordance at all until a `RandomizeSkills` port lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup`; `CharGenState::RandomizeTemplate`; `CharGenState::RandomizeSkills`; `CharGenState::SetStartArea` random-bound call site | +| AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (this round's placeholder), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | | AP-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) — 49 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-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) @@ -405,7 +405,7 @@ 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.** The Appearance (`0x100003d4`, `gmCGAppearancePage`) and Summary (`0x100003d6`, `gmCGSummaryPage`) page roots mount as EMPTY, content-inert placeholders — visible/reachable through the master shell's free tab navigation (a player can click their tabs and land on a blank page) but with none of retail's own controls built: no gender/spin/color-wheel/preview on Appearance, no name field/summary listbox/static preview on Summary. Explicitly scoped out per the campaign plan (CC6a/CC6b own Appearance + the 3D preview; CC5 owns Summary + the Finish gate's real UI). The master shell already ports retail's OWN visibility/state-toggle/tab-selection mechanics for both pages faithfully — only their CONTENT is stopgapped. | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`_appearancePageRoot`/`_summaryPageRoot`, mounted but no page controller attached) | Explicitly sequenced follow-on slices (CC5, CC6a, CC6b) own this content; building it here would duplicate work already scoped to those slices and risk drifting from their own DAT/decomp research (Appearance's gender/appearance controls, Summary's name-input filter and Finish gate). | A player reaching Appearance or Summary via free tab navigation sees an empty page instead of retail's controls; Finish stays ghosted (see AP-211's sibling gate) so no create can complete through this screen until CC5 lands. | `gmCGAppearancePage @ 0x0047de70`; `gmCGSummaryPage` (InitializePage @ 136566 per the campaign plan); `docs/plans/2026-08-15-character-creation-campaign.md` (Slices CC5/CC6a/CC6b) | +| TS-82 | **Filed 2026-08-15 at Campaign CC slice CC4.** The Appearance (`0x100003d4`, `gmCGAppearancePage`) and Summary (`0x100003d6`, `gmCGSummaryPage`) page roots mount as EMPTY, content-inert placeholders — visible/reachable through the master shell's free tab navigation (a player can click their tabs and land on a blank page) but with none of retail's own controls built: no gender/spin/color-wheel/preview on Appearance, no name field/summary listbox/static preview on Summary. Explicitly scoped out per the campaign plan (CC6a/CC6b own Appearance + the 3D preview; CC5 owns Summary + the Finish gate's real UI). The master shell already ports retail's OWN visibility/state-toggle/tab-selection mechanics for both pages faithfully — only their CONTENT is stopgapped. | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`_appearancePageRoot`/`_summaryPageRoot`, mounted but no page controller attached) | Explicitly sequenced follow-on slices (CC5, CC6a, CC6b) own this content; building it here would duplicate work already scoped to those slices and risk drifting from their own DAT/decomp research (Appearance's gender/appearance controls, Summary's name-input filter and Finish gate). | A player reaching Appearance or 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. | `gmCGAppearancePage @ 0x0047de70`; `gmCGSummaryPage` (InitializePage @ 136566 per the campaign plan); `docs/plans/2026-08-15-character-creation-campaign.md` (Slices CC5/CC6a/CC6b) | | 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 e2264c9f..d5e955c3 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -251,7 +251,7 @@ the user gate. | CC1 | REVIEW-CLOSED 2026-08-15 | `04450041`, `cb4703e8` | CLOSED (fix round + narrow re-review; every citation independently re-derived) | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. **Review fix round (`cb4703e8`):** F1 doc corrected — Custom IS template index 0 (the Adventurer row), per `gmCGProfessionPage::UpdateProfession @ 0x004821b0` (case 0 → button 0x100003d9 / `ID_CharGen_CustomText`) and `CharGenState::SetTemplate @ 0x005C5A60` (commits via `CharGenState::ApplyTemplate @ 0x005C5080`, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (`ChargenOptions.GlobalSkillCostsBySkillId` from portal.dat 0x0E000004, `ChargenSkillCreditMath` checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`; F3 every `ChargenTableReader` collection is now frozen at projection (`ToFrozenDictionary`/`ToArray`, matching `MagicCatalog`'s pattern) including both `ChargenOptions.Empty` dictionaries; F4 a reflection guard test (`ChargenNoChoriziteLeakTests`) pins the no-Chorizite-leak contract by walking every public `AcDream.Core.CharGen` member; F5 `HasAnyAppearanceOptions`'s doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 `TryGetHeritage`/`TryGetStarterArea` annotated `[MaybeNullWhen(false)]` (matching the house `EmptyDatReaderWriter` pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's `PlayerFactory` heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. **Narrow re-review CLOSED:** the reviewer retro-graded F2 to HIGH (under the base commit 37 of 38 costable skills were charged zero) and confirmed the SkillBase.SpecializedCost->PrimaryCost mapping dodged the UpgradeCostFromTrainedToSpecialized trap. Residuals: R1 retail refunds +1 credit on a both-tier miss (port charges 0; unreachable via retail’s own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0. | | 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 | CODE-COMPLETE 2026-08-15 | this commit | Not yet reviewed (Opus dual-lens owed) | 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 idempotently with char-management (never nulled on close, so char-management's own per-tick set survives). **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()`. Headless is unaffected (`DirectGameRuntimeCommandAdapter`/`HeadlessSessionHost` never call `InstallOptions`, so headless bots keep the CC3-documented `ChargenOptions.Empty` default — matches the brief). **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 fresh `DatStringResolver` per call under `d.DatLock`, `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. | +| CC4 | CODE-COMPLETE 2026-08-15 | original + fix-round, both "this commit" | Dual-lens review returned 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. | | CC5 | — | | | | | CC6a | — | | | | | CC6b | — | | | | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index e1b93544..fe0d13f2 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -643,6 +643,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.DebugFont, controls, iconComposer); + // Review fix round F12 (2026-08-15): constructed ONCE per + // composition and captured by the ResolveText closure below, + // rather than a fresh DatStringResolver per lookup. The + // Heritage/Town pages' description composers each call + // ResolveText several times per Refresh, and CharacterCreation- + // UiController.ApplyProgressState forces a full refresh on + // every page switch (`_lastRevision = long.MinValue`) — so an + // unchached resolver meant several fresh allocations + DatLock + // acquisitions per click. DatStringResolver's own constructor + // does no DAT I/O (only .Resolve reads), so building it here + // outside the lock matches this file's existing pattern + // elsewhere (construct once, lock only around Resolve calls). + var characterCreationStrings = new DatStringResolver(d.Dats); var bindings = new RetailUiRuntimeBindings( Host: host, Assets: assets, @@ -987,7 +1000,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory { lock (d.DatLock) { - return new DatStringResolver(d.Dats).Resolve( + return characterCreationStrings.Resolve( 0x23000002u, DatStringResolver.ComputeHash(key)); } diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs index e9274c71..659bf90e 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -73,21 +73,35 @@ internal sealed class CharacterCreationHeritagePage : IDisposable }; private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Action _onButtonClicked; private readonly Dictionary _buttons = []; private readonly UiText? _description; private bool _disposed; + /// Review fix round F3 (2026-08-15): + /// invoked with the RAW button element id (not the resolved heritage + /// id) on every heritage-button click, before + /// runs — mirrors retail's message bubbling from + /// gmCGHeritagePage::ListenToElementMessage up to + /// gmCharGenMainUI::ListenToElementMessage's own tab-restore + /// arm, which is keyed on the same raw id. internal CharacterCreationHeritagePage( UiElement pageRoot, - CharacterCreationRuntimeBindings bindings) + CharacterCreationRuntimeBindings bindings, + Action onButtonClicked) { _bindings = bindings; + _onButtonClicked = onButtonClicked; foreach ((uint buttonId, uint heritageId) in HeritageByButtonId) { if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) continue; _buttons[button] = heritageId; - button.OnClick = () => Select(heritageId); + button.OnClick = () => + { + _onButtonClicked(buttonId); + Select(heritageId); + }; } _description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs index 315003aa..e5ef6434 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs @@ -147,8 +147,12 @@ internal sealed class CharacterCreationProfessionPage : IDisposable foreach ((ChargenAttributeId attribute, SliderWidgets widgets) in _sliders) { int value = GetAttribute(snapshot.Attributes, attribute); - float scalar = (value - ChargenAttributeMath.AttributeMin) - / (float)(ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin); + // gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d: + // SetAttribute_Float(pSlider, 0x86, value * 0.00999999978f) — + // scalar = value/100, NOT (value-AttributeMin)/(AttributeMax- + // AttributeMin). Review fix round F2 (2026-08-15): the earlier + // [10,100]<->[0,1] normalization here did not match retail. + float scalar = value / 100f; widgets.Slider?.SetScalarPosition(scalar); widgets.Value?.SetText(value.ToString(CultureInfo.InvariantCulture)); if (widgets.Lock is { } lockButton) @@ -211,14 +215,21 @@ internal sealed class CharacterCreationProfessionPage : IDisposable _bindings.SelectTemplate(templateIndex); } + /// + /// gmCGProfessionPage::ListenToElementMessage @ 0x004829c0, the + /// scrollbar-drag case (relative id 0x100002ee, idMessage 0xa): + /// ebx = _ftol2(param*100); if (ebx < 0xa) ebx = 0xa; + /// SetAttribValue(this, parent, ebx) — truncate (not round) the + /// scalar times 100, clamp LOW only to 10, with NO upper clamp/rescale. + /// Review fix round F2 (2026-08-15): the earlier + /// AttributeMin+Round(scalar*(Max-Min)) formula here did not match + /// retail (it only happened to agree with retail at scalar=1). + /// private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar) { if (_disposed) return; - int value = ChargenAttributeMath.AttributeMin - + (int)MathF.Round( - scalar * (ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin), - MidpointRounding.AwayFromZero); + int value = Math.Max(ChargenAttributeMath.AttributeMin, (int)(scalar * 100f)); _bindings.SetAttribute(attribute, value); } diff --git a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs index 4aeadb51..e296d907 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs @@ -40,7 +40,32 @@ internal sealed class CharacterCreationTownPage : IDisposable [3] = "ID_CharGen_SanamarText", }; + /// + /// Start-area index -> the page's OWN retail state literal — a + /// SEPARATE state machine from CharacterCreationUiController's + /// master-page per-page-index cycling + /// (0x10000025 + (page - 1)). gmCGTownPage::SetTown @ + /// 0x0047c360 calls this->vtable->SetState(...) (the + /// gmCGTownPage/page-root object itself) with these four literals + /// verbatim, alongside the per-button highlight state — note these do + /// NOT sit in button/startArea numeric order: Holtburg->0x10000034, + /// Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035. + /// Re-asserted directly (inlined, bypassing SetTown) at the Sanamar + /// click site @0x0047c518. Review fix round F4 (2026-08-15): only the + /// master page's state cycling was ported — this page's own state was + /// missed entirely. + /// + private static readonly IReadOnlyDictionary PageStateByStartArea = + new Dictionary + { + [0] = 0x10000034u, // Holtburg + [1] = 0x10000037u, // Shoushi + [2] = 0x10000036u, // Yaraq + [3] = 0x10000035u, // Sanamar + }; + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly UiElement _pageRoot; private readonly Dictionary _buttons = []; private readonly UiText? _description; private bool _disposed; @@ -50,6 +75,7 @@ internal sealed class CharacterCreationTownPage : IDisposable CharacterCreationRuntimeBindings bindings) { _bindings = bindings; + _pageRoot = pageRoot; foreach ((uint buttonId, int startArea) in StartAreaByButtonId) { if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) @@ -68,6 +94,12 @@ internal sealed class CharacterCreationTownPage : IDisposable foreach ((UiButton button, int startArea) in _buttons) button.Selected = startArea == snapshot.StartArea; + if (PageStateByStartArea.TryGetValue(snapshot.StartArea, out uint pageStateId) + && _pageRoot is IUiDatStateful stateful) + { + stateful.TrySetRetailState(pageStateId); + } + if (_description is null) return; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index 0ef76c20..1c36cef0 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -201,18 +201,21 @@ internal sealed class CharacterCreationUiController : IDisposable Root.ClickThrough = false; Root.Visible = false; // AD-98: the same authored 800x600 fixed-canvas treatment as the - // character-management screen — see that controller's own comment. - // Both screens author the identical extent, so it is safe for both - // controllers to independently (idempotently) push the SAME value - // to the shared UiRoot.FixedCanvasSize; this controller therefore - // never NULLS it back out on close (see Deactivate/Close), leaving - // char-management's own per-tick set as the surviving owner once - // this screen is not the active one. + // character-management screen. CharacterManagementUiController sets + // UiRoot.FixedCanvasSize ONCE on its own activation edge + // (Tick's `if (!_active)` arm) and NULLS it in both Deactivate AND + // Dispose — it is NOT a per-tick set, and this controller must be + // symmetric with that exact shape (review fix round F1, 2026-08-15 + // — the earlier claim here that it was safe to leave the canvas + // pinned forever was FALSE and left an 800x600-scaled canvas + // covering the in-world UI whenever this screen had been opened). + // See Open/Close/Deactivate/Dispose below for the matching set/null + // pair. _authoredCanvas = new Vector2( Root.Width > 0f ? Root.Width : 800f, Root.Height > 0f ? Root.Height : 600f); - _heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings); + _heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings, ApplyHeritageTabRestore); _professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings); _skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver); _townPage = new CharacterCreationTownPage(townPageRoot, bindings); @@ -354,7 +357,6 @@ internal sealed class CharacterCreationUiController : IDisposable if (_isOpen) { Root.Visible = true; - _host.FixedCanvasSize = _authoredCanvas; _host.BringToFront(Root); } else @@ -378,19 +380,27 @@ internal sealed class CharacterCreationUiController : IDisposable /// Opens the screen at retail's authored default page /// (gmCharGenMainUI::gmCharGenMainUI's trailing - /// SetProgressState(this, ECG_HERTAGE)). + /// SetProgressState(this, ECG_HERTAGE)). Sets the fixed canvas + /// on this exact activation edge — matching + /// 's own one-shot set — + /// not per-tick; // + /// null it back out symmetrically. internal void Open() { if (_disposed) return; _isOpen = true; + _host.FixedCanvasSize = _authoredCanvas; ApplyProgressState(Page.Heritage); } private void Close() { + if (!_isOpen) + return; _isOpen = false; Root.Visible = false; + _host.FixedCanvasSize = null; } public void Dispose() @@ -404,6 +414,10 @@ internal sealed class CharacterCreationUiController : IDisposable } finally { + // Matches CharacterManagementUiController.Dispose's own + // unconditional null — defends against disposing while _isOpen + // (Close() is not otherwise called on this path). + _host.FixedCanvasSize = null; _back.OnClick = null; _next.OnClick = null; _finish.OnClick = null; @@ -609,6 +623,60 @@ internal sealed class CharacterCreationUiController : IDisposable stateful.TrySetRetailState(stateId); } + // ── Heritage tab-restore (gmCharGenMainUI::ListenToElementMessage @ ──── + // ── 0x004e9450, the heritage-button bubble arm) ───────────────────── + + /// SHOW ids (label_4e9673, three SetVisible(1) calls) — + /// verbatim off the decompiled switch's case list at + /// 0x004e9450. + private static readonly IReadOnlySet HeritageTabShowButtonIds = new HashSet + { + 0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u, + 0x10000590u, 0x10000591u, 0x100005A9u, 0x100005BFu, + 0x100005C4u, 0x100005E8u, + }; + + /// HIDE ids (@0x004e96b9, three SetVisible(0) calls) — + /// the Olthoi/OlthoiAcid heritage buttons. + private static readonly IReadOnlySet HeritageTabHideButtonIds = new HashSet + { + 0x100005C7u, 0x100005C8u, + }; + + /// + /// Ports gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's + /// heritage-button tab-restore arm: heritage-button clicks bubble to + /// the master shell and SYNCHRONOUSLY show/hide the Profession/Skills/ + /// Town tabs, independent of 's own + /// tab-visibility recompute at page-switch time (that recompute only + /// runs when Back/Next/a tab is clicked — not on every heritage pick). + /// Retail quirk reproduced faithfully: Lugian's button id + /// (0x100005f1) sits OUTSIDE both the SHOW and HIDE case lists + /// in the decompiled switch, so clicking Lugian neither restores nor + /// hides the tabs — a genuine retail bug (the tabs stay in whatever + /// state the PREVIOUS heritage selection left them), not an acdream + /// omission. Review fix round F3 (2026-08-15): this arm was entirely + /// unported — before this fix, selecting a human heritage right after + /// Olthoi/OlthoiAcid left the tabs hidden until the next Back/Next/tab + /// click recomputed them. + /// + private void ApplyHeritageTabRestore(uint buttonElementId) + { + if (HeritageTabShowButtonIds.Contains(buttonElementId)) + { + _professionTab.Visible = true; + _skillsTab.Visible = true; + _townTab.Visible = true; + } + else if (HeritageTabHideButtonIds.Contains(buttonElementId)) + { + _professionTab.Visible = false; + _skillsTab.Visible = false; + _townTab.Visible = false; + } + // Else (including Lugian, 0x100005f1): no-op, matching retail. + } + private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot) { // Local-refusal / rejection surfacing is CC5's Summary-page job @@ -622,9 +690,8 @@ internal sealed class CharacterCreationUiController : IDisposable if (_active) { _active = false; - _isOpen = false; _openOnStartConsumed = false; - Root.Visible = false; + Close(); } CloseAllDialogs(suppressCallbacks: true); } diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index 36e628b8..4ebaaaf4 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -1715,10 +1715,10 @@ public static class ItemAppraisalTextFormatter _ => string.Empty, }; - /// AppraisalSystem::SkillToString @ 0x005B4A30. - /// Retail skill-id -> display-name table. Made internal - /// (Campaign CC slice CC4) so the chargen Skills page can reuse the - /// same names instead of duplicating this table. + /// AppraisalSystem::SkillToString @ 0x005B4A30 — retail + /// skill-id -> display-name table. Made internal (Campaign CC + /// slice CC4) so the chargen Skills page can reuse the same names + /// instead of duplicating this table. internal static string SkillName(int skill) => skill switch { 1 => "Axe", diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 8f0735bd..2b1ef090 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -3,6 +3,7 @@ using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Plugins; using AcDream.Headless.Policies; +using AcDream.Content.CharGen; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime; @@ -310,6 +311,22 @@ internal sealed class HeadlessSessionHost : IDisposable { runtime.CharacterOwner.InstallSpellMetadata( content.MagicCatalog.SpellTable); + // Review fix round F6 (2026-08-15): mirrors the spell- + // metadata install directly above — without this, a + // content-bearing headless host's ChargenOptions stayed + // ChargenOptions.Empty (LiveSessionController's own + // construction default) forever, so + // RuntimeCharacterCreationState refused every chargen + // command (TrySelectHeritage etc. all validate against + // Options) even though CharacterCreated/CreationFailed were + // already wired below. A content-less host (contentLease is + // null, e.g. a bot that never needs to create a character) + // is still a validated-legal configuration per the R9 note + // near _contentLease's other reads — it simply cannot issue + // chargen commands, matching a content-less host's existing + // inability to resolve spell/collision data either. + runtime.Session.CharacterCreationState.InstallOptions( + ChargenTableReader.Load(content.Dats)); } gameplay.Bind( runtime, diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 4eba5e94..244c6a7f 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -915,6 +915,9 @@ public sealed class LiveSessionController _inWorld = true; _activeSelection = selection; CharacterSelectionState.CompleteEnter(selection.CharacterId); + // CC4 review-fix F1: same in-world edge as selection's own + // CompleteEnter above. + CharacterCreationState.CompleteEnter(); host.ApplyEnteredWorld(selection); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); @@ -1206,6 +1209,10 @@ public sealed class LiveSessionController _inWorld = true; _activeSelection = selection; CharacterSelectionState.CompleteEnter(character.CharacterId); + // CC4 review-fix F1: covers BOTH callers of this shared core + // (EnterSelectedCore and EnterCreatedCharacterCore) — the same + // in-world edge as selection's own CompleteEnter above. + CharacterCreationState.CompleteEnter(); scope.Host.ApplyEnteredWorld(selection); if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index 85696ced..05a79951 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -400,6 +400,39 @@ public sealed class RuntimeCharacterCreationState : IDisposable Publish(RuntimeCharacterCreationDeltaKind.Reset); } + /// + /// Campaign CC slice CC4 review-fix round (F1): the character-creation + /// analogue of . + /// Unlike selection (whose IsActive is computed from a + /// Lifecycle enum that already has an InWorld state), + /// creation has no lifecycle enum — this flips + /// (and therefore ) + /// straight to , mirroring selection's OBSERVABLE + /// effect at the same call sites (LiveSessionController.StartCore + /// and EnterHighlightedCore, both already call + /// CharacterSelectionState.CompleteEnter at the exact point the + /// session transitions in-world). Session field data (heritage/gender/ + /// name/etc.) is left untouched — only clears it, + /// matching selection's own CompleteEnter, which does not clear its + /// roster either. Before this fix, nothing ever cleared + /// between and the NEXT + /// /, so the creation view + /// reported active for an entire in-world session — the CC4 review's + /// F1 finding (a permanently re-pinned UiRoot.FixedCanvasSize + /// once the chargen screen had ever been opened). + /// + internal void CompleteEnter() + { + lock (_gate) + { + if (_disposed || !_active) + return; + _active = false; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + } + internal void Reset(RuntimeGenerationToken generation) { lock (_gate) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index e8e8216d..2f2c201f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -312,6 +312,192 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(2, environment.Runtime.LastSelectedStartArea); } + /// Review fix round F4 (2026-08-15): gmCGTownPage::SetTown + /// @ 0x0047c360 also sets the TOWN PAGE'S OWN retail state via a + /// literal per-town map — Holtburg->0x10000034, Yaraq->0x10000036 — + /// SEPARATE from the master page's per-page-index cycling + /// (0x10000025+page, already covered by the page-switch tests + /// above). + [Fact] + public void TownButton_Refresh_SetsThePagesOwnRetailStateLiteral() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.TownTabElementId) + .OnClick!(); + + var pageRoot = Assert.IsType( + environment.Page(CharacterCreationUiController.TownPageElementId)); + + environment.Button(0x1000040Du).OnClick!(); // Holtburg -> startArea 0 + BumpRevisionAndTick(environment); + Assert.Equal("Holtburg", pageRoot.ActiveState); + + environment.Button(0x1000040Eu).OnClick!(); // Yaraq -> startArea 2 + BumpRevisionAndTick(environment); + Assert.Equal("Yaraq", pageRoot.ActiveState); + } + + /// Review fix round F2 (2026-08-15), the display direction: + /// gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d sets + /// the slider's scalar position to value * 0.00999999978f + /// (value/100), not a [10,100]-to-[0,1] rescale. + [Fact] + public void ProfessionSlider_Refresh_DisplaysScalarAsValueOverOneHundred() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId) + .OnClick!(); + + UiElement strengthContainer = Assert.IsAssignableFrom( + environment.Screen.FindElement(0x100003E6u)); + var slider = Assert.IsType( + UiElement.FindDescendant(strengthContainer, 0x100002EEu)); + + RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with + { + Revision = snapshot.Revision + 1, + Attributes = snapshot.Attributes with { Strength = 55 }, + }; + environment.Controller.Tick(); + + Assert.Equal(0.55f, slider.ScalarPosition); + } + + /// Review fix round F2 (2026-08-15), the drag-inverse + /// direction: ListenToElementMessage @ 0x004829c0's scrollbar- + /// drag case truncates scalar*100 and clamps LOW only to 10 — + /// NOT the [10,100]<->[0,1] rescale the previous (wrong) formula + /// used, which only coincidentally agreed with the correct one at + /// scalar=1 (the pre-existing + /// case). + [Theory] + [InlineData(0.5f, 50)] + [InlineData(0f, 10)] + public void ProfessionSlider_ScalarChange_TruncatesAndClampsLowOnly( + float scalar, + int expectedValue) + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId) + .OnClick!(); + + UiElement strengthContainer = Assert.IsAssignableFrom( + environment.Screen.FindElement(0x100003E6u)); + var slider = Assert.IsType( + UiElement.FindDescendant(strengthContainer, 0x100002EEu)); + + slider.ScalarChanged!(scalar); + + Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet); + Assert.Equal(expectedValue, environment.Runtime.LastAttributeValue); + } + + /// Review fix round F3 (2026-08-15): + /// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's + /// heritage-button bubble arm shows/hides the Profession/Skills/Town + /// tabs SYNCHRONOUSLY at click time — independent of + /// 's + /// page-switch-time recompute (no tab/Back/Next click happens in this + /// test at all). Lugian (0x100005f1) sits outside BOTH the SHOW + /// and HIDE case lists in the decompiled switch — a genuine retail + /// quirk, reproduced faithfully. + [Fact] + public void HeritageButtonClick_RestoresHiddenTabsAtClickTime_ExceptLugian() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(0x100005C7u).OnClick!(); // Olthoi -> HIDE + Assert.False(environment.TabButton( + CharacterCreationUiController.ProfessionTabElementId).Visible); + Assert.False(environment.TabButton( + CharacterCreationUiController.SkillsTabElementId).Visible); + Assert.False(environment.TabButton( + CharacterCreationUiController.TownTabElementId).Visible); + + environment.Button(0x100005F1u).OnClick!(); // Lugian -> no-op quirk + Assert.False(environment.TabButton( + CharacterCreationUiController.ProfessionTabElementId).Visible); + Assert.False(environment.TabButton( + CharacterCreationUiController.SkillsTabElementId).Visible); + Assert.False(environment.TabButton( + CharacterCreationUiController.TownTabElementId).Visible); + + environment.Button(0x100003BFu).OnClick!(); // Aluvian -> SHOW + Assert.True(environment.TabButton( + CharacterCreationUiController.ProfessionTabElementId).Visible); + Assert.True(environment.TabButton( + CharacterCreationUiController.SkillsTabElementId).Visible); + Assert.True(environment.TabButton( + CharacterCreationUiController.TownTabElementId).Visible); + } + + /// Review fix round F1 (2026-08-15): Open() sets + /// UiRoot.FixedCanvasSize once on the activation edge (matching + /// CharacterManagementUiController's real, non-per-tick shape); + /// Close()/Deactivate()/Dispose() null it back out + /// symmetrically. Before this fix nothing ever nulled it, so an + /// 800x600-scaled canvas silently covered the in-world UI for the rest + /// of the session once this screen had ever been opened. + [Fact] + public void Open_SetsFixedCanvas_ExitConfirmClosesAndNullsIt() + { + using var environment = new EnvironmentHarness(); + Assert.Null(environment.Host.FixedCanvasSize); + + environment.Controller.Open(); + Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize); + + environment.Button(CharacterCreationUiController.ExitElementId).OnClick!(); + environment.ConfirmActiveDialog(confirmed: true); + + Assert.Null(environment.Host.FixedCanvasSize); + } + + [Fact] + public void Deactivate_NullsFixedCanvas_AndClosesTheScreen() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + Assert.NotNull(environment.Host.FixedCanvasSize); + + // Runtime reporting the view inactive/gone (e.g. entering the + // world) must Deactivate -- previously nothing drove this because + // RuntimeCharacterCreationState had no CompleteEnter() analogue; + // this test exercises the CONTROLLER side of that fix directly by + // simulating the view disappearing. + environment.Runtime.ProvideView = false; + environment.Controller.Tick(); + + Assert.False(environment.Controller.Root.Visible); + Assert.Null(environment.Host.FixedCanvasSize); + } + + [Fact] + public void Dispose_NullsFixedCanvas() + { + var environment = new EnvironmentHarness(); + environment.Controller.Open(); + Assert.NotNull(environment.Host.FixedCanvasSize); + + environment.Dispose(); + + Assert.Null(environment.Host.FixedCanvasSize); + } + + private static void BumpRevisionAndTick(EnvironmentHarness environment) + { + RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 }; + environment.Controller.Tick(); + } + private static IEnumerable Descendants(UiElement root) { yield return root; @@ -680,6 +866,7 @@ public sealed class CharacterCreationUiControllerTests }; page.Children.Add(ButtonInfo(0x100003BFu)); // Aluvian page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi + page.Children.Add(ButtonInfo(0x100005F1u)); // Lugian (F3 quirk: no tab-restore/hide) page.Children.Add(TextInfo(0x100003C4u)); return page; } @@ -749,6 +936,14 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ButtonInfo(0x1000040Eu)); // Yaraq page.Children.Add(ButtonInfo(0x1000040Fu)); // Shoushi page.Children.Add(TextInfo(0x10000409u)); + + // F4: the page ROOT's own retail state literal map + // (gmCGTownPage::SetTown @ 0x0047c360), a separate state machine + // from the master page's per-page-index cycling. + page.States[0x10000034u] = new UiStateInfo { Id = 0x10000034u, Name = "Holtburg" }; + page.States[0x10000035u] = new UiStateInfo { Id = 0x10000035u, Name = "Sanamar" }; + page.States[0x10000036u] = new UiStateInfo { Id = 0x10000036u, Name = "Yaraq" }; + page.States[0x10000037u] = new UiStateInfo { Id = 0x10000037u, Name = "Shoushi" }; return page; }